feat(ume): key alarm forward to OClaw via WSS and AI monitor UI

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-06-22 11:17:31 +08:00
parent 9a39ddfcc7
commit 1f1b42e8bf
18 changed files with 1079 additions and 32 deletions

View file

@ -24,10 +24,7 @@ function App() {
typeof integrationsQuery.data?.db?.latency_ms === "number" ? integrationsQuery.data.db.latency_ms : undefined,
oclawBridge:
integrationsQuery.data?.oclaw_bridge?.status ?? (integrationsQuery.isError ? "down" : "unknown"),
oclawBridgeLatencyMs:
typeof integrationsQuery.data?.oclaw_bridge?.latency_ms === "number"
? integrationsQuery.data.oclaw_bridge.latency_ms
: undefined,
oclawBridgeLatencyMs: undefined,
oclawBridgeErrorKind:
typeof integrationsQuery.data?.oclaw_bridge?.error_kind === "string"
? integrationsQuery.data.oclaw_bridge.error_kind
@ -36,6 +33,14 @@ function App() {
typeof integrationsQuery.data?.oclaw_bridge?.error === "string"
? integrationsQuery.data.oclaw_bridge.error
: undefined,
oclawBridgeQueueSize:
typeof integrationsQuery.data?.oclaw_bridge?.queue_size === "number"
? integrationsQuery.data.oclaw_bridge.queue_size
: undefined,
oclawBridgePublishedOk:
typeof integrationsQuery.data?.oclaw_bridge?.published_ok === "number"
? integrationsQuery.data.oclaw_bridge.published_ok
: undefined,
}}
>
<Routes>

View file

@ -4,6 +4,7 @@ export const queryKeys = {
integrationsStatus: ["integrationsStatus"] as const,
umeTokenStatus: ["umeTokenStatus"] as const,
umeAlarmSubscription: ["umeAlarmSubscription"] as const,
umeKeyAlertMonitor: ["umeKeyAlertMonitor"] as const,
/** Prefix keys for invalidating all pages/variants of a domain. */
umeSyncStatusAll: ["umeSyncStatus"] as const,
umeNEAll: ["umeNE"] as const,

View file

@ -35,7 +35,7 @@ const en = {
titleCollect: "Batch Collect",
navUme: "UME",
netxApi: "netx api",
oclawBridge: "oclaw bridge",
oclawBridge: "oclaw WSS",
language: "Language",
moreMenu: "More",
langZh: "中文",
@ -335,6 +335,36 @@ const en = {
wsAutoRefresh: "Auto refresh",
wsLogsEmpty: "No logs yet (connect WSS after establishing subscription)",
},
keyAlert: {
title: "AI alarm monitor (OClaw WhatsApp)",
help: "Match UME key alarms by notificationId and push via OClaw WebSocket. Uses NETX_OCLAW_ALARM_WS_URL + NETX_OCLAW_ALARM_WS_TOKEN (OClaw OCLAW_NETX_BRIDGE_TOKEN), separate from analyze token.",
ws: "OClaw WSS",
wsConnected: "connected",
wsDisconnected: "disconnected",
wsDisabled: "disabled",
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",
adding: "Adding…",
delete: "Delete",
pickFromAlarms: "Pick from current alarms",
emptyRules: "No notificationId rules yet",
colId: "notificationId",
colLabel: "label",
colPublished: "published",
colAttempts: "attempts",
colLast: "last push",
colActions: "actions",
addOk: "Rule added",
deleteOk: "Deleted",
confirmDelete: "Delete this notificationId monitor rule?",
},
sync: {
title: "UME sync",
inventory: "Sync inventory",

View file

@ -35,7 +35,7 @@ const zh = {
titleCollect: "批量采集",
navUme: "UME 对接",
netxApi: "netx api",
oclawBridge: "oclaw bridge",
oclawBridge: "oclaw WSS",
language: "语言",
moreMenu: "更多",
langZh: "中文",
@ -333,6 +333,36 @@ const zh = {
wsAutoRefresh: "自动刷新",
wsLogsEmpty: "暂无日志(建立订阅并连接 WSS 后会出现连接、收包、重连等记录)",
},
keyAlert: {
title: "AI 告警监控(OClaw WhatsApp)",
help: "按 notificationId 匹配 UME 关键告警,经 OClaw WebSocket 推送到 WhatsApp 群。使用独立配置:NETX_OCLAW_ALARM_WS_URL + NETX_OCLAW_ALARM_WS_TOKEN(对应 OClaw OCLAW_NETX_BRIDGE_TOKEN)。",
ws: "OClaw WSS",
wsConnected: "已连接",
wsDisconnected: "未连接",
wsDisabled: "未启用",
publishedOk: "发布成功",
publishedFail: "发布失败",
queue: "待发队列",
notificationId: "notificationId",
notificationIdPh: "输入或从下方选择 notificationId",
label: "规则描述",
labelPh: "可选,如 BGP Down",
forwardOnClear: "转发清除告警",
add: "添加监控 ID",
adding: "添加中…",
delete: "删除",
pickFromAlarms: "从当前告警选择",
emptyRules: "尚未配置监控 notificationId",
colId: "notificationId",
colLabel: "描述",
colPublished: "发布次数",
colAttempts: "尝试次数",
colLast: "最近推送",
colActions: "操作",
addOk: "已添加监控规则",
deleteOk: "已删除",
confirmDelete: "确定删除该 notificationId 监控规则?",
},
sync: {
title: "UME 同步",
inventory: "同步 Inventory",

View file

@ -17,6 +17,8 @@ type Props = {
oclawBridgeLatencyMs?: number;
oclawBridgeErrorKind?: string;
oclawBridgeError?: string;
oclawBridgeQueueSize?: number;
oclawBridgePublishedOk?: number;
};
children: ReactNode;
};
@ -29,11 +31,15 @@ export function AppLayout({ connections, children }: Props) {
const netxSuffix =
typeof connections.netxApiLatencyMs === "number" ? ` (${connections.netxApiLatencyMs}ms)` : "";
const oclawSuffix =
typeof connections.oclawBridgeLatencyMs === "number"
? ` (${connections.oclawBridgeLatencyMs}ms)`
connections.oclawBridge === "up"
? typeof connections.oclawBridgePublishedOk === "number"
? ` (pub ${connections.oclawBridgePublishedOk})`
: ""
: connections.oclawBridgeErrorKind
? ` (${connections.oclawBridgeErrorKind})`
: "";
: typeof connections.oclawBridgeQueueSize === "number" && connections.oclawBridgeQueueSize > 0
? ` (q ${connections.oclawBridgeQueueSize})`
: "";
const oclawTitle =
connections.oclawBridge === "down" && connections.oclawBridgeError
? connections.oclawBridgeError

View file

@ -9,9 +9,13 @@ import {
clearLocalUmeAlarmSubscription,
establishUmeAlarmSubscription,
fetchUmeAlarmSubscriptionStatus,
fetchUmeKeyAlertMonitor,
fetchUmeNotificationIds,
fetchUmeSyncStatus,
fetchUmeTokenStatus,
refreshUmeToken,
upsertUmeKeyAlertRule,
deleteUmeKeyAlertRule,
} from "../services/api";
import { HelpHint } from "../components/HelpHint";
import { queryKeys } from "../constants/queryKeys";
@ -42,6 +46,10 @@ export function UmePage() {
const [curPage, setCurPage] = useState(1);
const [curPageSize, setCurPageSize] = useState(50);
const [alarmsPanelOpen, setAlarmsPanelOpen] = useState(false);
const [keyAlertNotificationId, setKeyAlertNotificationId] = useState("");
const [keyAlertLabel, setKeyAlertLabel] = useState("");
const [keyAlertForwardOnClear, setKeyAlertForwardOnClear] = useState(false);
const [keyAlertOpError, setKeyAlertOpError] = useState("");
const syncMutation = useMutation({
mutationFn: async (domains: string[]) => apiPost<{ ok: boolean; jobs: unknown[] }>("/v1/ume/sync", { domains }),
@ -91,6 +99,18 @@ export function UmePage() {
staleTime: 3000,
refetchInterval: 5000,
});
const keyAlertMonitorQuery = useQuery({
queryKey: queryKeys.umeKeyAlertMonitor,
queryFn: fetchUmeKeyAlertMonitor,
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"));
@ -273,6 +293,44 @@ export function UmePage() {
onError: (err) => setRuntimeTaskError(String(err)),
});
const keyAlertAddMutation = useMutation({
mutationFn: () =>
upsertUmeKeyAlertRule({
notification_id: keyAlertNotificationId.trim(),
label: keyAlertLabel.trim(),
enabled: true,
forward_on_clear: keyAlertForwardOnClear,
}),
onMutate: () => setKeyAlertOpError(""),
onSuccess: async () => {
setKeyAlertNotificationId("");
setKeyAlertLabel("");
showOk(t("ume.keyAlert.addOk"));
await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor });
await queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus });
},
onError: (err) => {
const msg = String(err);
setKeyAlertOpError(msg);
showError(msg);
},
});
const keyAlertDeleteMutation = useMutation({
mutationFn: (notificationId: string) => deleteUmeKeyAlertRule(notificationId),
onMutate: () => setKeyAlertOpError(""),
onSuccess: async () => {
showOk(t("ume.keyAlert.deleteOk"));
await queryClient.invalidateQueries({ queryKey: queryKeys.umeKeyAlertMonitor });
await queryClient.invalidateQueries({ queryKey: queryKeys.integrationsStatus });
},
onError: (err) => {
const msg = String(err);
setKeyAlertOpError(msg);
showError(msg);
},
});
const syncTotal = Number(syncStatusQuery.data?.total || 0);
const syncPages = pageCount(syncTotal, syncPageSize);
const neTotal = Number(neQuery.data?.total || 0);
@ -280,6 +338,15 @@ export function UmePage() {
const curTotal = Number(currentQuery.data?.total || 0);
const curPages = pageCount(curTotal, curPageSize);
const keyAlertForwarder = keyAlertMonitorQuery.data?.forwarder;
const keyAlertRules = keyAlertMonitorQuery.data?.rules || [];
const oclawWsPill =
!keyAlertForwarder?.enabled
? "unknown"
: keyAlertForwarder.connected
? "up"
: "down";
const perPage = (n: number) => t("common.perPage", { n: String(n) });
return (
@ -511,6 +578,139 @@ export function UmePage() {
</div>
</article>
<article className="card card--full">
<h3 className="card-title-with-hint">
{t("ume.keyAlert.title")}
<HelpHint text={t("ume.keyAlert.help")} ariaLabel={t("common.help")} />
</h3>
<div className="actions-row actions-row--inline">
<span className={`conn-pill conn-pill--${oclawWsPill}`}>
{t("ume.keyAlert.ws")}:{" "}
{!keyAlertForwarder?.enabled
? t("ume.keyAlert.wsDisabled")
: keyAlertForwarder.connected
? t("ume.keyAlert.wsConnected")
: t("ume.keyAlert.wsDisconnected")}
</span>
{keyAlertForwarder?.enabled ? (
<>
<span className="conn-pill">
{t("ume.keyAlert.publishedOk")}: {Number(keyAlertForwarder.published_ok || 0)}
</span>
<span className="conn-pill">
{t("ume.keyAlert.publishedFail")}: {Number(keyAlertForwarder.published_fail || 0)}
</span>
<span className="conn-pill">
{t("ume.keyAlert.queue")}: {Number(keyAlertForwarder.queue_size || 0)}
</span>
</>
) : 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 }}>
<input
type="checkbox"
checked={keyAlertForwardOnClear}
onChange={(e) => setKeyAlertForwardOnClear(e.target.checked)}
/>
{t("ume.keyAlert.forwardOnClear")}
</label>
<button
type="button"
onClick={() => notificationIdsQuery.refetch()}
disabled={notificationIdsQuery.isFetching}
>
{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>
</div>
{keyAlertOpError ? (
<div className="pill pill--high" style={{ marginTop: 8 }}>
{t("common.opFailed")}: {keyAlertOpError}
</div>
) : null}
<table style={{ marginTop: 12 }}>
<thead>
<tr>
<th>{t("ume.keyAlert.colId")}</th>
<th>{t("ume.keyAlert.colLabel")}</th>
<th>{t("ume.keyAlert.colPublished")}</th>
<th>{t("ume.keyAlert.colAttempts")}</th>
<th>{t("ume.keyAlert.colLast")}</th>
<th>{t("ume.keyAlert.colActions")}</th>
</tr>
</thead>
<tbody>
{keyAlertRules.map((rule) => (
<tr key={rule.notification_id}>
<td>{rule.notification_id}</td>
<td>{rule.label || t("common.empty")}</td>
<td>{Number(rule.forward_stats?.published_ok || 0)}</td>
<td>{Number(rule.forward_stats?.attempts || 0)}</td>
<td>
{rule.forward_stats?.last_forwarded_at
? formatSystemTime(rule.forward_stats.last_forwarded_at)
: 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>
</td>
</tr>
))}
{!keyAlertMonitorQuery.isLoading && keyAlertRules.length === 0 ? (
<tr>
<td colSpan={6}>{t("ume.keyAlert.emptyRules")}</td>
</tr>
) : null}
</tbody>
</table>
</article>
<article className="card card--full">
<h3>{t("ume.sync.title")}</h3>
<div className="actions-row actions-row--inline">

View file

@ -10,6 +10,7 @@ import type {
ManagedNeMeta,
UmeAlarmItem,
UmeAlarmSubscriptionStatus,
UmeKeyAlertMonitorResponse,
UmeNeItem,
UmeSyncStatusResponse,
UmeTokenStatus,
@ -71,6 +72,23 @@ export const cancelUmeAlarmSubscription = (opts?: { forceClearLocal?: boolean })
export const clearLocalUmeAlarmSubscription = () =>
apiPost<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/clear-local", {});
export const fetchUmeKeyAlertMonitor = () => apiGet<UmeKeyAlertMonitorResponse>("/v1/ume/key-alert-monitor");
export const upsertUmeKeyAlertRule = (payload: {
notification_id: string;
label?: string;
enabled?: boolean;
forward_on_clear?: boolean;
}) => apiPost<{ ok: boolean }>("/v1/ume/key-alert-rules", payload);
export const deleteUmeKeyAlertRule = (notificationId: string) =>
apiDelete<{ ok: boolean }>(`/v1/ume/key-alert-rules/${encodeURIComponent(notificationId)}`);
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 fetchUmeSyncStatus = (params: { page: number; pageSize: number }) => {
const p = new URLSearchParams();
p.set("page", String(Math.max(1, Number(params.page || 1))));

View file

@ -3,12 +3,49 @@ export type IntegrationStatus = {
db: { status: "up" | "down" | "unknown"; latency_ms?: number; error?: string; [k: string]: unknown };
oclaw_bridge?: {
status: "up" | "down" | "unknown";
mode?: string;
enabled?: boolean;
connected?: boolean;
queue_size?: number;
published_ok?: number;
published_fail?: number;
latency_ms?: number;
error_kind?: string;
error?: string;
[k: string]: unknown;
};
};
export type UmeKeyAlertRuleItem = {
notification_id: string;
enabled: boolean;
forward_on_clear: boolean;
label: string;
created_at: string;
updated_at: string;
forward_stats?: {
attempts: number;
published_ok: number;
last_forwarded_at: string;
};
};
export type UmeKeyAlertForwarderStatus = {
enabled: boolean;
connected: boolean;
queue_size: number;
url: string;
published_ok?: number;
published_fail?: number;
queued_total?: number;
};
export type UmeKeyAlertMonitorResponse = {
ok: boolean;
rules: UmeKeyAlertRuleItem[];
forwarder: UmeKeyAlertForwarderStatus;
};
export type UmeSyncJobItem = {
id: number;
domain: string;