mirror of
https://github.com/hansjone/netx.git
synced 2026-10-16 06:30:44 +08:00
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 <cursoragent@cursor.com>
This commit is contained in:
parent
34db62c8d2
commit
b54a6e5ad4
12 changed files with 266 additions and 68 deletions
63
netx_api/key_alert_config.py
Normal file
63
netx_api/key_alert_config.py
Normal file
|
|
@ -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)}
|
||||||
|
|
@ -141,6 +141,8 @@ def match_key_alert_rule(
|
||||||
continue
|
continue
|
||||||
return rule
|
return rule
|
||||||
if act == "deleted":
|
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 rule
|
||||||
return None
|
return None
|
||||||
|
|
|
||||||
|
|
@ -60,6 +60,11 @@ from .ume_alarm_ws import (
|
||||||
start_ume_alarm_ws_consumer,
|
start_ume_alarm_ws_consumer,
|
||||||
)
|
)
|
||||||
from .ume_sync_service import sync_alarms_current, sync_alarms_history_full, sync_inventory_full
|
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 (
|
from .key_alert_matcher import (
|
||||||
invalidate_key_alert_rule_cache,
|
invalidate_key_alert_rule_cache,
|
||||||
normalize_match_type,
|
normalize_match_type,
|
||||||
|
|
@ -687,10 +692,52 @@ def _configure_ume_diag_logging() -> None:
|
||||||
lg.propagate = False
|
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")
|
@app.on_event("startup")
|
||||||
def on_startup() -> None:
|
def on_startup() -> None:
|
||||||
_configure_ume_diag_logging()
|
_configure_ume_diag_logging()
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
_migrate_key_alert_rule_schema()
|
||||||
_reset_runtime_pause_flags()
|
_reset_runtime_pause_flags()
|
||||||
_fail_stale_running_sync_jobs_on_startup()
|
_fail_stale_running_sync_jobs_on_startup()
|
||||||
if _needs_startup_alarm_sync_before_ws():
|
if _needs_startup_alarm_sync_before_ws():
|
||||||
|
|
@ -777,20 +824,6 @@ def on_startup() -> None:
|
||||||
conn.exec_driver_sql(
|
conn.exec_driver_sql(
|
||||||
"CREATE INDEX IF NOT EXISTS ix_ume_alarms_history_notification_id ON ume_alarms_history (notification_id)"
|
"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(
|
conn.exec_driver_sql(
|
||||||
"CREATE INDEX IF NOT EXISTS ix_ume_alarms_current_host_name ON ume_alarms_current (host_name)"
|
"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_type": rule_match_type(row),
|
||||||
"match_value": rule_match_value(row),
|
"match_value": rule_match_value(row),
|
||||||
"enabled": bool(int(row.enabled or 0)),
|
"enabled": bool(int(row.enabled or 0)),
|
||||||
"forward_on_clear": bool(int(row.forward_on_clear or 0)),
|
|
||||||
"label": str(row.label or ""),
|
"label": str(row.label or ""),
|
||||||
"created_at": (_ensure_utc(row.created_at) or datetime.now(timezone.utc)).isoformat(),
|
"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(),
|
"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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"rules": base.get("items") or [],
|
"rules": base.get("items") or [],
|
||||||
|
"config": get_key_alert_monitor_config(db),
|
||||||
"forwarder": base.get("forwarder") or forwarder_status(),
|
"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")
|
@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]:
|
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"))
|
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()
|
label = str(payload.get("label") or "").strip()
|
||||||
if not label:
|
if not label:
|
||||||
raise HTTPException(status_code=400, detail="label_required")
|
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
|
enabled = 1 if bool(payload.get("enabled", True)) else 0
|
||||||
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
now = datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
try:
|
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_type = match_type
|
||||||
row.match_value = match_value
|
row.match_value = match_value
|
||||||
row.enabled = enabled
|
row.enabled = enabled
|
||||||
row.forward_on_clear = forward_on_clear
|
|
||||||
row.label = label
|
row.label = label
|
||||||
row.updated_at = now
|
row.updated_at = now
|
||||||
saved = {
|
saved = {
|
||||||
|
|
@ -1307,7 +1346,6 @@ def ume_upsert_key_alert_rule(payload: dict[str, Any], db: Session = Depends(get
|
||||||
"match_type": match_type,
|
"match_type": match_type,
|
||||||
"match_value": match_value,
|
"match_value": match_value,
|
||||||
"enabled": bool(enabled),
|
"enabled": bool(enabled),
|
||||||
"forward_on_clear": bool(forward_on_clear),
|
|
||||||
"label": label,
|
"label": label,
|
||||||
}
|
}
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,16 @@ class UmeAlarmHistory(Base):
|
||||||
raw_json: Mapped[str] = mapped_column(Text, default="{}")
|
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):
|
class UmeKeyAlertRule(Base):
|
||||||
"""Key alert rule matched by UME notificationId or alarm description keyword."""
|
"""Key alert rule matched by UME notificationId or alarm description keyword."""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ from sqlalchemy import create_engine
|
||||||
from sqlalchemy.orm import sessionmaker
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
from netx_api.db import Base
|
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 (
|
from netx_api.key_alert_matcher import (
|
||||||
invalidate_key_alert_rule_cache,
|
invalidate_key_alert_rule_cache,
|
||||||
match_key_alert_rule,
|
match_key_alert_rule,
|
||||||
|
|
@ -24,9 +25,11 @@ class KeyAlertMatcherTests(unittest.TestCase):
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
self.db = self.SessionLocal()
|
self.db = self.SessionLocal()
|
||||||
invalidate_key_alert_rule_cache()
|
invalidate_key_alert_rule_cache()
|
||||||
|
invalidate_key_alert_config_cache()
|
||||||
|
|
||||||
def tearDown(self) -> None:
|
def tearDown(self) -> None:
|
||||||
self.db.close()
|
self.db.close()
|
||||||
|
invalidate_key_alert_config_cache()
|
||||||
|
|
||||||
def test_notification_id_from_norm(self) -> None:
|
def test_notification_id_from_norm(self) -> None:
|
||||||
norm = {"notificationId": "NID-1001", "is-cleared": False}
|
norm = {"notificationId": "NID-1001", "is-cleared": False}
|
||||||
|
|
@ -57,7 +60,6 @@ class KeyAlertMatcherTests(unittest.TestCase):
|
||||||
UmeKeyAlertRule(
|
UmeKeyAlertRule(
|
||||||
notification_id="NID-42",
|
notification_id="NID-42",
|
||||||
enabled=1,
|
enabled=1,
|
||||||
forward_on_clear=0,
|
|
||||||
label="test",
|
label="test",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -68,10 +70,7 @@ class KeyAlertMatcherTests(unittest.TestCase):
|
||||||
self.assertIsNotNone(rule)
|
self.assertIsNotNone(rule)
|
||||||
cleared = {"notificationId": "NID-42", "is-cleared": True}
|
cleared = {"notificationId": "NID-42", "is-cleared": True}
|
||||||
self.assertIsNone(match_key_alert_rule(self.db, norm=cleared, action="deleted"))
|
self.assertIsNone(match_key_alert_rule(self.db, norm=cleared, action="deleted"))
|
||||||
row = self.db.get(UmeKeyAlertRule, "NID-42")
|
set_key_alert_monitor_config(self.db, forward_on_clear=True)
|
||||||
assert row is not None
|
|
||||||
row.forward_on_clear = 1
|
|
||||||
self.db.commit()
|
|
||||||
invalidate_key_alert_rule_cache()
|
invalidate_key_alert_rule_cache()
|
||||||
self.assertIsNotNone(match_key_alert_rule(self.db, norm=cleared, action="deleted"))
|
self.assertIsNotNone(match_key_alert_rule(self.db, norm=cleared, action="deleted"))
|
||||||
|
|
||||||
|
|
@ -91,7 +90,6 @@ class KeyAlertMatcherTests(unittest.TestCase):
|
||||||
match_type="keyword",
|
match_type="keyword",
|
||||||
match_value="链路中断",
|
match_value="链路中断",
|
||||||
enabled=1,
|
enabled=1,
|
||||||
forward_on_clear=0,
|
|
||||||
label="链路",
|
label="链路",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
@ -118,7 +116,6 @@ class KeyAlertMatcherTests(unittest.TestCase):
|
||||||
match_type="keyword",
|
match_type="keyword",
|
||||||
match_value="BGP DOWN",
|
match_value="BGP DOWN",
|
||||||
enabled=1,
|
enabled=1,
|
||||||
forward_on_clear=0,
|
|
||||||
label="BGP",
|
label="BGP",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
text: string;
|
text: string;
|
||||||
|
|
@ -7,14 +8,63 @@ type Props = {
|
||||||
nowrap?: boolean;
|
nowrap?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const VIEWPORT_MARGIN = 8;
|
||||||
|
const POPOVER_MAX_WIDTH = 360;
|
||||||
|
|
||||||
export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: Props) {
|
export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: Props) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
const [popoverStyle, setPopoverStyle] = useState<React.CSSProperties>({});
|
||||||
const rootRef = useRef<HTMLSpanElement>(null);
|
const rootRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const popoverRef = useRef<HTMLDivElement>(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(() => {
|
useEffect(() => {
|
||||||
if (!open) return;
|
if (!open) return;
|
||||||
const onDocClick = (ev: MouseEvent) => {
|
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) => {
|
const onKey = (ev: KeyboardEvent) => {
|
||||||
if (ev.key === "Escape") setOpen(false);
|
if (ev.key === "Escape") setOpen(false);
|
||||||
|
|
@ -34,18 +84,29 @@ export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: P
|
||||||
className="help-hint__trigger"
|
className="help-hint__trigger"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-expanded={open}
|
aria-expanded={open}
|
||||||
onClick={() => setOpen((v) => !v)}
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen((v) => !v);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
?
|
?
|
||||||
</button>
|
</button>
|
||||||
{open ? (
|
{open
|
||||||
<div
|
? createPortal(
|
||||||
className={`help-hint__popover${nowrap ? " help-hint__popover--nowrap" : ""}`}
|
<div
|
||||||
role="tooltip"
|
ref={popoverRef}
|
||||||
>
|
className={`help-hint__popover help-hint__popover--portal${
|
||||||
{text}
|
nowrap ? " help-hint__popover--nowrap" : ""
|
||||||
</div>
|
}`}
|
||||||
) : null}
|
style={popoverStyle}
|
||||||
|
role="tooltip"
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)
|
||||||
|
: null}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -353,7 +353,7 @@ const en = {
|
||||||
label: "Rule description",
|
label: "Rule description",
|
||||||
labelPh: "Required, e.g. BGP Down",
|
labelPh: "Required, e.g. BGP Down",
|
||||||
forwardOnClear: "Push on alarm clear",
|
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",
|
add: "Add rules",
|
||||||
adding: "Adding…",
|
adding: "Adding…",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
|
|
|
||||||
|
|
@ -351,7 +351,7 @@ const zh = {
|
||||||
label: "规则描述",
|
label: "规则描述",
|
||||||
labelPh: "必填,如 BGP Down",
|
labelPh: "必填,如 BGP Down",
|
||||||
forwardOnClear: "告警清除时也推送",
|
forwardOnClear: "告警清除时也推送",
|
||||||
forwardOnClearHelp: "默认只在告警新增/更新时推送。勾选后,当 UME 上报告警已清除(从当前告警表删除)时,也会向 WhatsApp 发送一条清除通知。",
|
forwardOnClearHelp: "全局开关:对所有监控规则生效。默认只在告警新增/更新时推送;勾选后,UME 上报告警已清除时也会向 WhatsApp 发送清除通知。",
|
||||||
add: "添加规则",
|
add: "添加规则",
|
||||||
adding: "添加中…",
|
adding: "添加中…",
|
||||||
delete: "删除",
|
delete: "删除",
|
||||||
|
|
|
||||||
|
|
@ -708,6 +708,11 @@ pre {
|
||||||
right: 0;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.help-hint__popover--portal {
|
||||||
|
z-index: 2000;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.help-hint__popover--nowrap {
|
.help-hint__popover--nowrap {
|
||||||
width: max-content;
|
width: max-content;
|
||||||
max-width: min(90vw, 720px);
|
max-width: min(90vw, 720px);
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import {
|
||||||
refreshUmeToken,
|
refreshUmeToken,
|
||||||
upsertUmeKeyAlertRule,
|
upsertUmeKeyAlertRule,
|
||||||
deleteUmeKeyAlertRule,
|
deleteUmeKeyAlertRule,
|
||||||
|
updateUmeKeyAlertMonitorConfig,
|
||||||
} from "../services/api";
|
} from "../services/api";
|
||||||
import { HelpHint } from "../components/HelpHint";
|
import { HelpHint } from "../components/HelpHint";
|
||||||
import { queryKeys } from "../constants/queryKeys";
|
import { queryKeys } from "../constants/queryKeys";
|
||||||
|
|
@ -50,7 +51,6 @@ export function UmePage() {
|
||||||
const [keyAlertMatchType, setKeyAlertMatchType] = useState<"notification_id" | "keyword">("keyword");
|
const [keyAlertMatchType, setKeyAlertMatchType] = useState<"notification_id" | "keyword">("keyword");
|
||||||
const [keyAlertMatchValue, setKeyAlertMatchValue] = useState("");
|
const [keyAlertMatchValue, setKeyAlertMatchValue] = useState("");
|
||||||
const [keyAlertLabel, setKeyAlertLabel] = useState("");
|
const [keyAlertLabel, setKeyAlertLabel] = useState("");
|
||||||
const [keyAlertForwardOnClear, setKeyAlertForwardOnClear] = useState(false);
|
|
||||||
const [keyAlertOpError, setKeyAlertOpError] = useState("");
|
const [keyAlertOpError, setKeyAlertOpError] = useState("");
|
||||||
const [keyAlertKeywordHints, setKeyAlertKeywordHints] = useState<string[]>([]);
|
const [keyAlertKeywordHints, setKeyAlertKeywordHints] = useState<string[]>([]);
|
||||||
const [keyAlertIdHints, setKeyAlertIdHints] = useState<
|
const [keyAlertIdHints, setKeyAlertIdHints] = useState<
|
||||||
|
|
@ -300,7 +300,6 @@ export function UmePage() {
|
||||||
match_value: keyAlertMatchValue.trim(),
|
match_value: keyAlertMatchValue.trim(),
|
||||||
label: keyAlertLabel.trim(),
|
label: keyAlertLabel.trim(),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
forward_on_clear: keyAlertForwardOnClear,
|
|
||||||
}),
|
}),
|
||||||
onMutate: () => setKeyAlertOpError(""),
|
onMutate: () => setKeyAlertOpError(""),
|
||||||
onSuccess: async () => {
|
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 syncTotal = Number(syncStatusQuery.data?.total || 0);
|
||||||
const syncPages = pageCount(syncTotal, syncPageSize);
|
const syncPages = pageCount(syncTotal, syncPageSize);
|
||||||
const neTotal = Number(neQuery.data?.total || 0);
|
const neTotal = Number(neQuery.data?.total || 0);
|
||||||
|
|
@ -341,6 +353,7 @@ export function UmePage() {
|
||||||
|
|
||||||
const keyAlertForwarder = keyAlertMonitorQuery.data?.forwarder;
|
const keyAlertForwarder = keyAlertMonitorQuery.data?.forwarder;
|
||||||
const keyAlertRules = keyAlertMonitorQuery.data?.rules || [];
|
const keyAlertRules = keyAlertMonitorQuery.data?.rules || [];
|
||||||
|
const keyAlertForwardOnClear = Boolean(keyAlertMonitorQuery.data?.config?.forward_on_clear);
|
||||||
const oclawWsPill =
|
const oclawWsPill =
|
||||||
!keyAlertForwarder?.enabled
|
!keyAlertForwarder?.enabled
|
||||||
? "unknown"
|
? "unknown"
|
||||||
|
|
@ -697,23 +710,22 @@ export function UmePage() {
|
||||||
className="actions-row actions-row--inline"
|
className="actions-row actions-row--inline"
|
||||||
style={{ marginTop: 8, flexWrap: "wrap", alignItems: "center", justifyContent: "space-between" }}
|
style={{ marginTop: 8, flexWrap: "wrap", alignItems: "center", justifyContent: "space-between" }}
|
||||||
>
|
>
|
||||||
<label
|
<span style={{ display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}>
|
||||||
className="muted"
|
<label className="muted" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
|
||||||
style={{ display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}
|
<input
|
||||||
>
|
type="checkbox"
|
||||||
<input
|
checked={keyAlertForwardOnClear}
|
||||||
type="checkbox"
|
disabled={keyAlertConfigMutation.isPending || keyAlertMonitorQuery.isLoading}
|
||||||
checked={keyAlertForwardOnClear}
|
onChange={(e) => keyAlertConfigMutation.mutate(e.target.checked)}
|
||||||
onChange={(e) => setKeyAlertForwardOnClear(e.target.checked)}
|
/>
|
||||||
/>
|
{t("ume.keyAlert.forwardOnClear")}
|
||||||
{t("ume.keyAlert.forwardOnClear")}
|
</label>
|
||||||
<HelpHint
|
<HelpHint
|
||||||
text={t("ume.keyAlert.forwardOnClearHelp")}
|
text={t("ume.keyAlert.forwardOnClearHelp")}
|
||||||
ariaLabel={t("common.help")}
|
ariaLabel={t("common.help")}
|
||||||
align="end"
|
align="start"
|
||||||
nowrap
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor })}
|
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor })}
|
||||||
|
|
@ -734,7 +746,6 @@ export function UmePage() {
|
||||||
<th>{t("ume.keyAlert.colType")}</th>
|
<th>{t("ume.keyAlert.colType")}</th>
|
||||||
<th>{t("ume.keyAlert.colMatch")}</th>
|
<th>{t("ume.keyAlert.colMatch")}</th>
|
||||||
<th>{t("ume.keyAlert.colLabel")}</th>
|
<th>{t("ume.keyAlert.colLabel")}</th>
|
||||||
<th>{t("ume.keyAlert.colForwardClear")}</th>
|
|
||||||
<th>{t("ume.keyAlert.colPublished")}</th>
|
<th>{t("ume.keyAlert.colPublished")}</th>
|
||||||
<th>{t("ume.keyAlert.colAttempts")}</th>
|
<th>{t("ume.keyAlert.colAttempts")}</th>
|
||||||
<th>{t("ume.keyAlert.colLast")}</th>
|
<th>{t("ume.keyAlert.colLast")}</th>
|
||||||
|
|
@ -751,7 +762,6 @@ export function UmePage() {
|
||||||
</td>
|
</td>
|
||||||
<td>{rule.match_value || rule.notification_id}</td>
|
<td>{rule.match_value || rule.notification_id}</td>
|
||||||
<td>{rule.label || t("common.empty")}</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?.published_ok || 0)}</td>
|
||||||
<td>{Number(rule.forward_stats?.attempts || 0)}</td>
|
<td>{Number(rule.forward_stats?.attempts || 0)}</td>
|
||||||
<td>
|
<td>
|
||||||
|
|
@ -776,7 +786,7 @@ export function UmePage() {
|
||||||
))}
|
))}
|
||||||
{!keyAlertMonitorQuery.isLoading && keyAlertRules.length === 0 ? (
|
{!keyAlertMonitorQuery.isLoading && keyAlertRules.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={8}>{t("ume.keyAlert.emptyRules")}</td>
|
<td colSpan={7}>{t("ume.keyAlert.emptyRules")}</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,17 @@ import type {
|
||||||
UmeTokenStatus,
|
UmeTokenStatus,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
|
|
||||||
|
const parseApiResponse = async (res: Response): Promise<Record<string, unknown>> => {
|
||||||
|
const text = await res.text();
|
||||||
|
if (!text) return {};
|
||||||
|
try {
|
||||||
|
return JSON.parse(text) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
const snippet = text.replace(/\s+/g, " ").trim().slice(0, 160);
|
||||||
|
throw new Error(res.ok ? "invalid_json_response" : `${res.status} ${snippet || res.statusText}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const apiGet = async <T,>(path: string): Promise<T> => {
|
export const apiGet = async <T,>(path: string): Promise<T> => {
|
||||||
const res = await fetch(path, { headers: { accept: "application/json" } });
|
const res = await fetch(path, { headers: { accept: "application/json" } });
|
||||||
if (!res.ok) throw new Error(`${res.status} ${path}`);
|
if (!res.ok) throw new Error(`${res.status} ${path}`);
|
||||||
|
|
@ -28,9 +39,8 @@ export const apiPost = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||||
headers: { "content-type": "application/json", accept: "application/json" },
|
headers: { "content-type": "application/json", accept: "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const text = await res.text();
|
const data = await parseApiResponse(res);
|
||||||
const data = text ? JSON.parse(text) : {};
|
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||||
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} ${path}`));
|
|
||||||
return data as T;
|
return data as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -40,17 +50,15 @@ export const apiPatch = async <T,>(path: string, body: unknown): Promise<T> => {
|
||||||
headers: { "content-type": "application/json", accept: "application/json" },
|
headers: { "content-type": "application/json", accept: "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const text = await res.text();
|
const data = await parseApiResponse(res);
|
||||||
const data = text ? JSON.parse(text) : {};
|
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||||
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} ${path}`));
|
|
||||||
return data as T;
|
return data as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiDelete = async <T,>(path: string): Promise<T> => {
|
export const apiDelete = async <T,>(path: string): Promise<T> => {
|
||||||
const res = await fetch(path, { method: "DELETE", headers: { accept: "application/json" } });
|
const res = await fetch(path, { method: "DELETE", headers: { accept: "application/json" } });
|
||||||
const text = await res.text();
|
const data = await parseApiResponse(res);
|
||||||
const data = text ? JSON.parse(text) : {};
|
if (!res.ok) throw new Error(String(data.detail || `${res.status} ${path}`));
|
||||||
if (!res.ok) throw new Error(String((data as { detail?: string }).detail || `${res.status} ${path}`));
|
|
||||||
return data as T;
|
return data as T;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -79,9 +87,11 @@ export const upsertUmeKeyAlertRule = (payload: {
|
||||||
match_value: string;
|
match_value: string;
|
||||||
label: string;
|
label: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
forward_on_clear?: boolean;
|
|
||||||
}) => apiPost<{ ok: boolean; item?: unknown }>("/v1/ume/key-alert-rules", payload);
|
}) => apiPost<{ ok: boolean; item?: unknown }>("/v1/ume/key-alert-rules", payload);
|
||||||
|
|
||||||
|
export const updateUmeKeyAlertMonitorConfig = (payload: { forward_on_clear: boolean }) =>
|
||||||
|
apiPatch<{ ok: boolean; config: { forward_on_clear: boolean } }>("/v1/ume/key-alert-monitor/config", payload);
|
||||||
|
|
||||||
export const deleteUmeKeyAlertRule = (ruleKey: string) =>
|
export const deleteUmeKeyAlertRule = (ruleKey: string) =>
|
||||||
apiDelete<{ ok: boolean }>(`/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`);
|
apiDelete<{ ok: boolean }>(`/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,6 @@ export type UmeKeyAlertRuleItem = {
|
||||||
match_type: "notification_id" | "keyword";
|
match_type: "notification_id" | "keyword";
|
||||||
match_value: string;
|
match_value: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
forward_on_clear: boolean;
|
|
||||||
label: string;
|
label: string;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|
@ -45,6 +44,9 @@ export type UmeKeyAlertForwarderStatus = {
|
||||||
export type UmeKeyAlertMonitorResponse = {
|
export type UmeKeyAlertMonitorResponse = {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
rules: UmeKeyAlertRuleItem[];
|
rules: UmeKeyAlertRuleItem[];
|
||||||
|
config?: {
|
||||||
|
forward_on_clear: boolean;
|
||||||
|
};
|
||||||
forwarder: UmeKeyAlertForwarderStatus;
|
forwarder: UmeKeyAlertForwarderStatus;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue