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:
oliver 2026-06-23 20:44:33 +08:00
parent d5d6d4eada
commit e474c0a431
11 changed files with 383 additions and 24 deletions

View file

@ -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,

View file

@ -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",

View file

@ -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: "尝试次数",

View file

@ -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,

View file

@ -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}
</>
);
}

View file

@ -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))));

View file

@ -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;