feat(ume): keyword key-alert rules with label and case-insensitive match

Add description keyword matching alongside notificationId rules, require
per-rule labels, and improve the AI monitor form layout and clear-on-push UX.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-22 16:48:01 +08:00
parent 99e7b1557f
commit 34db62c8d2
11 changed files with 449 additions and 134 deletions

View file

@ -4,9 +4,10 @@ type Props = {
text: string;
ariaLabel: string;
align?: "start" | "end";
nowrap?: boolean;
};
export function HelpHint({ text, ariaLabel, align = "start" }: Props) {
export function HelpHint({ text, ariaLabel, align = "start", nowrap = false }: Props) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLSpanElement>(null);
@ -38,7 +39,10 @@ export function HelpHint({ text, ariaLabel, align = "start" }: Props) {
?
</button>
{open ? (
<div className="help-hint__popover" role="tooltip">
<div
className={`help-hint__popover${nowrap ? " help-hint__popover--nowrap" : ""}`}
role="tooltip"
>
{text}
</div>
) : null}

View file

@ -337,7 +337,7 @@ const en = {
},
keyAlert: {
title: "AI alarm monitor (OClaw WhatsApp)",
help: "Match UME key alarms by notificationId and push via OClaw WebSocket. Auth shares NETX_OCLAW_ANALYZE_TOKEN / OCLAW_OPS_AI_SHARED_TOKEN; set NETX_OCLAW_ALARM_WS_URL separately.",
help: "Exact match by notificationId, or substring match on alarm description (case-insensitive; nativeProbableCause, etc.), pushed via OClaw WebSocket. Shares NETX_OCLAW_ANALYZE_TOKEN.",
ws: "OClaw WSS",
wsConnected: "connected",
wsDisconnected: "disconnected",
@ -345,25 +345,33 @@ const en = {
publishedOk: "published ok",
publishedFail: "published fail",
queue: "queue",
notificationId: "notificationId",
notificationIdPh: "Enter or pick notificationId",
label: "label",
labelPh: "optional, e.g. BGP Down",
forwardOnClear: "forward clear events",
add: "Add monitor ID",
matchType: "Match by",
matchNotificationId: "notificationId",
matchKeyword: "description keyword",
matchValuePhNotificationId: "Enter notificationId",
matchValuePhKeyword: "Enter keyword (case-insensitive)",
label: "Rule description",
labelPh: "Required, e.g. BGP Down",
forwardOnClear: "Push on alarm clear",
forwardOnClearHelp: "By default only new/updated alarms are pushed. When enabled, a clear notification is also sent when UME reports the alarm cleared.",
add: "Add rules",
adding: "Adding…",
delete: "Delete",
pickFromAlarms: "Pick from current alarms",
emptyRules: "No notificationId rules yet",
colId: "notificationId",
colLabel: "label",
pickFromAlarms: "Load alarm hints",
emptyRules: "No monitor rules yet",
colType: "type",
colMatch: "match",
colLabel: "description",
colForwardClear: "on clear",
colPublished: "published",
colAttempts: "attempts",
colLast: "last push",
colActions: "actions",
addOk: "Rule added",
addOk: "Monitor rule added",
deleteOk: "Deleted",
confirmDelete: "Delete this notificationId monitor rule?",
confirmDelete: "Delete this monitor rule?",
yes: "yes",
no: "no",
},
sync: {
title: "UME sync",

View file

@ -335,7 +335,7 @@ const zh = {
},
keyAlert: {
title: "AI 告警监控(OClaw WhatsApp)",
help: "按 notificationId 匹配 UME 关键告警,经 OClaw WebSocket 推送到 WhatsApp 群。鉴权与 NETX_OCLAW_ANALYZE_TOKEN / OCLAW_OPS_AI_SHARED_TOKEN 共用;另需配置 NETX_OCLAW_ALARM_WS_URL。",
help: "按 notificationId 精确匹配,或按告警描述关键字模糊匹配(不区分大小写,匹配 nativeProbableCause 等字段),经 OClaw WebSocket 推送到 WhatsApp 群。鉴权与 NETX_OCLAW_ANALYZE_TOKEN 共用。",
ws: "OClaw WSS",
wsConnected: "已连接",
wsDisconnected: "未连接",
@ -343,25 +343,33 @@ const zh = {
publishedOk: "发布成功",
publishedFail: "发布失败",
queue: "待发队列",
notificationId: "notificationId",
notificationIdPh: "输入或从下方选择 notificationId",
matchType: "匹配方式",
matchNotificationId: "告警码 (notificationId)",
matchKeyword: "告警描述关键字",
matchValuePhNotificationId: "输入 notificationId",
matchValuePhKeyword: "输入匹配关键字(不区分大小写)",
label: "规则描述",
labelPh: "可选,如 BGP Down",
forwardOnClear: "转发清除告警",
add: "添加监控 ID",
labelPh: "必填,如 BGP Down",
forwardOnClear: "告警清除时也推送",
forwardOnClearHelp: "默认只在告警新增/更新时推送。勾选后,当 UME 上报告警已清除(从当前告警表删除)时,也会向 WhatsApp 发送一条清除通知。",
add: "添加规则",
adding: "添加中…",
delete: "删除",
pickFromAlarms: "从当前告警选择",
emptyRules: "尚未配置监控 notificationId",
colId: "notificationId",
pickFromAlarms: "加载当前告警参考",
emptyRules: "尚未配置监控规则",
colType: "类型",
colMatch: "匹配内容",
colLabel: "描述",
colForwardClear: "清除推送",
colPublished: "发布次数",
colAttempts: "尝试次数",
colLast: "最近推送",
colActions: "操作",
addOk: "已添加监控规则",
deleteOk: "已删除",
confirmDelete: "确定删除该 notificationId 监控规则?",
confirmDelete: "确定删除该监控规则?",
yes: "是",
no: "否",
},
sync: {
title: "UME 同步",

View file

@ -708,6 +708,12 @@ pre {
right: 0;
}
.help-hint__popover--nowrap {
width: max-content;
max-width: min(90vw, 720px);
white-space: nowrap;
}
.panel__toolbar-end {
margin-left: auto;
display: flex;

View file

@ -9,6 +9,7 @@ import {
clearLocalUmeAlarmSubscription,
establishUmeAlarmSubscription,
fetchUmeAlarmSubscriptionStatus,
fetchUmeAlarmKeywords,
fetchUmeKeyAlertMonitor,
fetchUmeNotificationIds,
fetchUmeSyncStatus,
@ -46,10 +47,15 @@ export function UmePage() {
const [curPage, setCurPage] = useState(1);
const [curPageSize, setCurPageSize] = useState(50);
const [alarmsPanelOpen, setAlarmsPanelOpen] = useState(false);
const [keyAlertNotificationId, setKeyAlertNotificationId] = useState("");
const [keyAlertMatchType, setKeyAlertMatchType] = useState<"notification_id" | "keyword">("keyword");
const [keyAlertMatchValue, setKeyAlertMatchValue] = useState("");
const [keyAlertLabel, setKeyAlertLabel] = useState("");
const [keyAlertForwardOnClear, setKeyAlertForwardOnClear] = useState(false);
const [keyAlertOpError, setKeyAlertOpError] = useState("");
const [keyAlertKeywordHints, setKeyAlertKeywordHints] = useState<string[]>([]);
const [keyAlertIdHints, setKeyAlertIdHints] = useState<
Array<{ notification_id: string; native_probable_cause_sample: string }>
>([]);
const syncMutation = useMutation({
mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }),
@ -105,12 +111,6 @@ export function UmePage() {
staleTime: 3000,
refetchInterval: 5000,
});
const notificationIdsQuery = useQuery({
queryKey: ["umeNotificationIds"],
queryFn: () => fetchUmeNotificationIds(200),
staleTime: 10000,
enabled: false,
});
const confirmClearLocalSubscription = (hint?: string) =>
window.confirm(hint || t("ume.subscription.confirmClearDefault"));
@ -296,14 +296,15 @@ export function UmePage() {
const keyAlertAddMutation = useMutation({
mutationFn: () =>
upsertUmeKeyAlertRule({
notification_id: keyAlertNotificationId.trim(),
match_type: keyAlertMatchType,
match_value: keyAlertMatchValue.trim(),
label: keyAlertLabel.trim(),
enabled: true,
forward_on_clear: keyAlertForwardOnClear,
}),
onMutate: () => setKeyAlertOpError(""),
onSuccess: async () => {
setKeyAlertNotificationId("");
setKeyAlertMatchValue("");
setKeyAlertLabel("");
showOk(t("ume.keyAlert.addOk"));
await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor });
@ -606,58 +607,121 @@ export function UmePage() {
</>
) : null}
</div>
<div className="actions-row actions-row--inline" style={{ marginTop: 10, flexWrap: "wrap" }}>
<input
className="input"
style={{ minWidth: 180 }}
placeholder={t("ume.keyAlert.notificationIdPh")}
value={keyAlertNotificationId}
onChange={(e) => setKeyAlertNotificationId(e.target.value)}
list="ume-notification-id-options"
/>
<datalist id="ume-notification-id-options">
{(notificationIdsQuery.data?.items || []).map((x) => (
<option key={x.notification_id} value={x.notification_id}>
{x.native_probable_cause_sample || x.notification_id}
</option>
))}
</datalist>
<input
className="input"
style={{ minWidth: 140 }}
placeholder={t("ume.keyAlert.labelPh")}
value={keyAlertLabel}
onChange={(e) => setKeyAlertLabel(e.target.value)}
/>
<label className="muted" style={{ display: "inline-flex", alignItems: "center", gap: 6 }}>
<div style={{ marginTop: 10 }}>
<div
className="actions-row actions-row--inline"
style={{ flexWrap: "wrap", alignItems: "center", gap: 8 }}
>
<label className="muted">{t("ume.keyAlert.matchType")}</label>
<select
className="input"
value={keyAlertMatchType}
onChange={(e) => {
setKeyAlertMatchType(e.target.value === "keyword" ? "keyword" : "notification_id");
setKeyAlertKeywordHints([]);
setKeyAlertIdHints([]);
}}
>
<option value="notification_id">{t("ume.keyAlert.matchNotificationId")}</option>
<option value="keyword">{t("ume.keyAlert.matchKeyword")}</option>
</select>
<input
type="checkbox"
checked={keyAlertForwardOnClear}
onChange={(e) => setKeyAlertForwardOnClear(e.target.checked)}
className="input"
style={{ flex: "1 1 180px", minWidth: 160 }}
placeholder={
keyAlertMatchType === "keyword"
? t("ume.keyAlert.matchValuePhKeyword")
: t("ume.keyAlert.matchValuePhNotificationId")
}
value={keyAlertMatchValue}
onChange={(e) => setKeyAlertMatchValue(e.target.value)}
list="ume-key-alert-match-options"
/>
{t("ume.keyAlert.forwardOnClear")}
</label>
<button
type="button"
onClick={() => notificationIdsQuery.refetch()}
disabled={notificationIdsQuery.isFetching}
<input
className="input"
style={{ flex: "1 1 140px", minWidth: 120 }}
placeholder={t("ume.keyAlert.labelPh")}
value={keyAlertLabel}
onChange={(e) => setKeyAlertLabel(e.target.value)}
/>
<datalist id="ume-key-alert-match-options">
{keyAlertMatchType === "keyword"
? keyAlertKeywordHints.map((kw) => <option key={kw} value={kw} />)
: keyAlertIdHints.map((x) => (
<option key={x.notification_id} value={x.notification_id}>
{x.native_probable_cause_sample || x.notification_id}
</option>
))}
</datalist>
<button
type="button"
onClick={async () => {
try {
if (keyAlertMatchType === "keyword") {
const resp = await fetchUmeAlarmKeywords(100);
const hints = (resp.items || []).map((x) => x.keyword).filter(Boolean);
setKeyAlertKeywordHints(hints);
if (hints.length && !keyAlertMatchValue.trim()) {
setKeyAlertMatchValue(hints[0]);
}
} else {
const resp = await fetchUmeNotificationIds(100);
const items = resp.items || [];
setKeyAlertIdHints(items);
if (items.length && !keyAlertMatchValue.trim()) {
const first = items[0];
setKeyAlertMatchValue(first.notification_id);
if (!keyAlertLabel.trim() && first.native_probable_cause_sample) {
setKeyAlertLabel(first.native_probable_cause_sample);
}
}
}
} catch (err) {
showError(String(err));
}
}}
>
{t("ume.keyAlert.pickFromAlarms")}
</button>
<button
type="button"
onClick={() => keyAlertAddMutation.mutate()}
disabled={
keyAlertAddMutation.isPending || !keyAlertMatchValue.trim() || !keyAlertLabel.trim()
}
>
{keyAlertAddMutation.isPending ? t("ume.keyAlert.adding") : t("ume.keyAlert.add")}
</button>
</div>
<div
className="actions-row actions-row--inline"
style={{ marginTop: 8, flexWrap: "wrap", alignItems: "center", justifyContent: "space-between" }}
>
{t("ume.keyAlert.pickFromAlarms")}
</button>
<button
type="button"
onClick={() => keyAlertAddMutation.mutate()}
disabled={keyAlertAddMutation.isPending || !keyAlertNotificationId.trim()}
>
{keyAlertAddMutation.isPending ? t("ume.keyAlert.adding") : t("ume.keyAlert.add")}
</button>
<button
type="button"
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor })}
disabled={keyAlertMonitorQuery.isFetching}
>
{keyAlertMonitorQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
<label
className="muted"
style={{ display: "inline-flex", alignItems: "center", gap: 6, whiteSpace: "nowrap" }}
>
<input
type="checkbox"
checked={keyAlertForwardOnClear}
onChange={(e) => setKeyAlertForwardOnClear(e.target.checked)}
/>
{t("ume.keyAlert.forwardOnClear")}
<HelpHint
text={t("ume.keyAlert.forwardOnClearHelp")}
ariaLabel={t("common.help")}
align="end"
nowrap
/>
</label>
<button
type="button"
onClick={() => queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor })}
disabled={keyAlertMonitorQuery.isFetching}
>
{keyAlertMonitorQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
</div>
</div>
{keyAlertOpError ? (
<div className="pill pill--high" style={{ marginTop: 8 }}>
@ -667,8 +731,10 @@ export function UmePage() {
<table style={{ marginTop: 12 }}>
<thead>
<tr>
<th>{t("ume.keyAlert.colId")}</th>
<th>{t("ume.keyAlert.colType")}</th>
<th>{t("ume.keyAlert.colMatch")}</th>
<th>{t("ume.keyAlert.colLabel")}</th>
<th>{t("ume.keyAlert.colForwardClear")}</th>
<th>{t("ume.keyAlert.colPublished")}</th>
<th>{t("ume.keyAlert.colAttempts")}</th>
<th>{t("ume.keyAlert.colLast")}</th>
@ -678,8 +744,14 @@ export function UmePage() {
<tbody>
{keyAlertRules.map((rule) => (
<tr key={rule.notification_id}>
<td>{rule.notification_id}</td>
<td>
{rule.match_type === "keyword"
? t("ume.keyAlert.matchKeyword")
: t("ume.keyAlert.matchNotificationId")}
</td>
<td>{rule.match_value || rule.notification_id}</td>
<td>{rule.label || t("common.empty")}</td>
<td>{rule.forward_on_clear ? t("ume.keyAlert.yes") : t("ume.keyAlert.no")}</td>
<td>{Number(rule.forward_stats?.published_ok || 0)}</td>
<td>{Number(rule.forward_stats?.attempts || 0)}</td>
<td>
@ -704,7 +776,7 @@ export function UmePage() {
))}
{!keyAlertMonitorQuery.isLoading && keyAlertRules.length === 0 ? (
<tr>
<td colSpan={6}>{t("ume.keyAlert.emptyRules")}</td>
<td colSpan={8}>{t("ume.keyAlert.emptyRules")}</td>
</tr>
) : null}
</tbody>

View file

@ -75,20 +75,26 @@ export const clearLocalUmeAlarmSubscription = () =>
export const fetchUmeKeyAlertMonitor = () => apiGet<UmeKeyAlertMonitorResponse>("/v1/ume/key-alert-monitor");
export const upsertUmeKeyAlertRule = (payload: {
notification_id: string;
label?: string;
match_type: "notification_id" | "keyword";
match_value: string;
label: string;
enabled?: boolean;
forward_on_clear?: boolean;
}) => apiPost<{ ok: boolean }>("/v1/ume/key-alert-rules", payload);
}) => apiPost<{ ok: boolean; item?: unknown }>("/v1/ume/key-alert-rules", payload);
export const deleteUmeKeyAlertRule = (notificationId: string) =>
apiDelete<{ ok: boolean }>(`/v1/ume/key-alert-rules/${encodeURIComponent(notificationId)}`);
export const deleteUmeKeyAlertRule = (ruleKey: string) =>
apiDelete<{ ok: boolean }>(`/v1/ume/key-alert-rules/${encodeURIComponent(ruleKey)}`);
export const fetchUmeNotificationIds = (limit = 200) =>
apiGet<{ items: Array<{ notification_id: string; native_probable_cause_sample: string }> }>(
`/v1/ume/notification-ids?limit=${encodeURIComponent(String(limit))}`,
);
export const fetchUmeAlarmKeywords = (limit = 200) =>
apiGet<{ items: Array<{ keyword: string; alarm_count: number }> }>(
`/v1/ume/alarm-keywords?limit=${encodeURIComponent(String(limit))}`,
);
export const fetchUmeSyncStatus = (params: { page: number; pageSize: number }) => {
const p = new URLSearchParams();
p.set("page", String(Math.max(1, Number(params.page || 1))));

View file

@ -18,6 +18,8 @@ export type IntegrationStatus = {
export type UmeKeyAlertRuleItem = {
notification_id: string;
match_type: "notification_id" | "keyword";
match_value: string;
enabled: boolean;
forward_on_clear: boolean;
label: string;