feat(ume): real-time current alarms via WebSocket subscription

Add WS consumer, persisted subscription store, manual subscribe/cancel APIs, and UI controls; extend sync and tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-05-22 20:27:43 +08:00
parent 21f7cfba41
commit 4f8735a83f
13 changed files with 1171 additions and 22 deletions

View file

@ -5,6 +5,9 @@ import {
disconnectUmeToken,
fetchUmeCurrentAlarms,
fetchUmeNe,
cancelUmeAlarmSubscription,
establishUmeAlarmSubscription,
fetchUmeAlarmSubscriptionStatus,
fetchUmeSyncStatus,
fetchUmeTokenStatus,
refreshUmeToken,
@ -21,6 +24,7 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
const queryClient = useQueryClient();
const [tokenOpError, setTokenOpError] = useState("");
const [runtimeTaskError, setRuntimeTaskError] = useState("");
const [subscriptionOpError, setSubscriptionOpError] = useState("");
const [syncPage, setSyncPage] = useState(1);
const [syncPageSize, setSyncPageSize] = useState(20);
const [neKeyword, setNeKeyword] = useState("");
@ -79,6 +83,53 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
toastError?.(msg);
},
});
const subscriptionStatusQuery = useQuery({
queryKey: ["umeAlarmSubscription"],
queryFn: fetchUmeAlarmSubscriptionStatus,
staleTime: 3000,
refetchInterval: 5000,
});
const subscriptionEstablishMutation = useMutation({
mutationFn: establishUmeAlarmSubscription,
onMutate: () => setSubscriptionOpError(""),
onSuccess: async (res) => {
if (!res?.active) {
const msg = "建立订阅未返回有效 id/uri";
setSubscriptionOpError(msg);
toastError?.(msg);
} else {
setSubscriptionOpError("");
toastOk?.(
res.already_exists
? "订阅已存在,未重复建立(WSS 将保持/恢复连接)"
: "告警订阅已建立,WSS 将自动连接",
);
}
await queryClient.invalidateQueries({ queryKey: ["umeAlarmSubscription"] });
await queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] });
},
onError: (err) => {
const msg = String(err);
setSubscriptionOpError(msg);
toastError?.(msg);
},
});
const subscriptionCancelMutation = useMutation({
mutationFn: cancelUmeAlarmSubscription,
onMutate: () => setSubscriptionOpError(""),
onSuccess: async () => {
setSubscriptionOpError("");
toastOk?.("告警订阅已取消");
await queryClient.invalidateQueries({ queryKey: ["umeAlarmSubscription"] });
await queryClient.invalidateQueries({ queryKey: ["umeSyncStatus"] });
},
onError: (err) => {
const msg = String(err);
setSubscriptionOpError(msg);
toastError?.(msg);
},
});
const tokenDisconnectMutation = useMutation({
mutationFn: disconnectUmeToken,
onMutate: () => {
@ -135,6 +186,14 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
});
const runningTasks = (syncStatusQuery.data?.items || []).filter((x) => String(x.status || "").toLowerCase() === "running");
const runtimeTasks = syncStatusQuery.data?.runtime_tasks || [];
const alarmSub =
subscriptionStatusQuery.data ??
syncStatusQuery.data?.alarm_subscription ??
({ active: false } as const);
const wsConsumer = runtimeTasks.find((t) => t.task === "alarms_current_ws_consumer");
const subscriptionActive = Boolean(alarmSub.active);
const subPending =
subscriptionEstablishMutation.isPending || subscriptionCancelMutation.isPending;
const runtimeTaskMutation = useMutation({
mutationFn: async (vars: { task: string; action: "pause" | "resume" }) =>
@ -228,6 +287,78 @@ export function UmePage({ toastOk, toastError }: UmePageProps) {
</div>
)}
</article>
<article className="card card--full">
<h3>UME 告警订阅(WebSocket)</h3>
<p className="muted" style={{ marginTop: 0 }}>
订阅需手动建立/取消;建立后(或重启后若库中仍有有效订阅)后台会自动连接 WSS 接收实时告警。
</p>
<div className="actions-row actions-row--inline">
<span className={`conn-pill conn-pill--${subscriptionActive ? "up" : "down"}`}>
订阅: {subscriptionActive ? "已建立" : "未建立"}
</span>
{subscriptionActive && alarmSub.subscription_id ? (
<span className="conn-pill" title={alarmSub.wss_uri || ""}>
id: {String(alarmSub.subscription_id).slice(0, 12)}…
</span>
) : null}
{wsConsumer ? (
<span
className={`conn-pill conn-pill--${
String(wsConsumer.last_error || "").includes("connected") ||
wsConsumer.status === "running"
? "up"
: String(wsConsumer.last_error || "").includes("no_subscription")
? "down"
: "unknown"
}`}
title={wsConsumer.last_error || ""}
>
WSS: {wsConsumer.last_error || wsConsumer.status || "-"}
</span>
) : null}
</div>
<div className="actions-row actions-row--inline">
<button
type="button"
onClick={() => subscriptionEstablishMutation.mutate()}
disabled={subPending || subscriptionActive || !hasToken}
title={!hasToken ? "请先登录 UME token" : undefined}
>
{subscriptionEstablishMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
建立订阅…
</>
) : (
"建立告警订阅"
)}
</button>
<button
type="button"
onClick={() => subscriptionCancelMutation.mutate()}
disabled={subPending || !subscriptionActive}
>
{subscriptionCancelMutation.isPending ? (
<>
<span className="inline-spinner" aria-hidden />
取消订阅…
</>
) : (
"取消告警订阅"
)}
</button>
<button
type="button"
onClick={() => queryClient.invalidateQueries({ queryKey: ["umeAlarmSubscription"] })}
disabled={subscriptionStatusQuery.isFetching}
>
刷新订阅状态
</button>
</div>
{subscriptionOpError ? (
<div className="pill pill--high">订阅操作失败: {subscriptionOpError}</div>
) : null}
</article>
<article className="card card--full">
<h3>UME 同步</h3>
<div className="actions-row actions-row--inline">

View file

@ -6,6 +6,7 @@ import type {
ImportHistoryItem,
IntegrationStatus,
UmeAlarmItem,
UmeAlarmSubscriptionStatus,
UmeNeItem,
UmeSyncStatusResponse,
UmeTokenStatus,
@ -100,6 +101,15 @@ export const fetchAlarms = (params: {
return apiGet<AlarmQueryResponse>(`/v1/alarms?${p.toString()}`);
};
export const fetchUmeAlarmSubscriptionStatus = () =>
apiGet<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/status");
export const establishUmeAlarmSubscription = () =>
apiPost<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/establish", {});
export const cancelUmeAlarmSubscription = () =>
apiPost<UmeAlarmSubscriptionStatus>("/v1/ume/alarm-subscription/cancel", {});
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

@ -89,12 +89,26 @@ export type UmeSyncJobItem = {
ended_at?: string | null;
};
export type UmeAlarmSubscriptionStatus = {
ok?: boolean;
created?: boolean;
already_exists?: boolean;
active: boolean;
subscription_id?: string;
wss_uri?: string;
topic?: string;
ws_consumer_status?: string;
ws_consumer_last_error?: string;
ws_consumer_last_run_at?: string | null;
};
export type UmeSyncStatusResponse = {
total?: number;
page?: number;
page_size?: number;
items: UmeSyncJobItem[];
latest_by_domain?: Record<string, UmeSyncJobItem>;
alarm_subscription?: UmeAlarmSubscriptionStatus;
runtime_tasks?: Array<{
task: string;
status: string;