Add SSE second-level status push for control monitor.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-30 13:32:28 +08:00
parent 7136adcaa9
commit b3ba0fd431
10 changed files with 273 additions and 41 deletions
+114 -7
View File
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from "react";
import { apiFetch, type NodeCard } from "../api";
import { apiFetch, clearSession, getToken, type NodeCard } from "../api";
function pickStrategy(n: NodeCard) {
const fleet = (n.fleet || {}) as Record<string, unknown>;
@@ -55,7 +55,8 @@ export default function MonitorPage() {
const [err, setErr] = useState("");
const [busy, setBusy] = useState<Record<number, string>>({});
const [selected, setSelected] = useState<Set<number>>(new Set());
const [pollSec, setPollSec] = useState(8);
const [sseSec, setSseSec] = useState(1);
const [liveState, setLiveState] = useState<"connecting" | "live" | "retry">("connecting");
const [detailId, setDetailId] = useState<number | null>(null);
const refresh = useCallback(async () => {
@@ -69,15 +70,109 @@ export default function MonitorPage() {
}, []);
useEffect(() => {
apiFetch<{ poll_interval_sec?: number }>("/api/auth/me")
apiFetch<{ sse_interval_sec?: number }>("/api/auth/me")
.then((m) => {
if (m.poll_interval_sec) setPollSec(m.poll_interval_sec);
if (m.sse_interval_sec && Number(m.sse_interval_sec) > 0) {
setSseSec(Number(m.sse_interval_sec));
}
})
.catch(() => undefined);
}, []);
useEffect(() => {
let cancelled = false;
let retryMs = 1000;
let timer: number | undefined;
const ac = new AbortController();
void refresh();
const id = window.setInterval(() => void refresh(), pollSec * 1000);
return () => window.clearInterval(id);
}, [refresh, pollSec]);
async function runStream() {
while (!cancelled) {
setLiveState(retryMs > 1000 ? "retry" : "connecting");
try {
const token = getToken();
const headers = new Headers({ Accept: "text/event-stream" });
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch("/api/nodes/status/stream", {
headers,
signal: ac.signal,
});
if (res.status === 401) {
clearSession();
setErr("登录已失效,请重新登录");
setLiveState("retry");
break;
}
if (!res.ok || !res.body) {
throw new Error(`SSE HTTP ${res.status}`);
}
setLiveState("live");
setErr("");
retryMs = 1000;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let eventName = "message";
while (!cancelled) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n");
buf = parts.pop() || "";
for (const rawLine of parts) {
const line = rawLine.replace(/\r$/, "");
if (line.startsWith("event:")) {
eventName = line.slice(6).trim() || "message";
continue;
}
if (line.startsWith("data:")) {
const data = line.slice(5).trim();
if (!data) continue;
try {
const parsed = JSON.parse(data) as {
nodes?: NodeCard[];
detail?: string;
};
if (eventName === "error") {
setErr(parsed.detail || "SSE 错误");
} else if (parsed.nodes) {
setNodes(parsed.nodes);
setErr("");
}
} catch {
/* ignore malformed chunk */
}
eventName = "message";
continue;
}
if (line === "") {
eventName = "message";
}
}
}
} catch (ex) {
if (cancelled || (ex instanceof DOMException && ex.name === "AbortError")) {
break;
}
setLiveState("retry");
setErr(ex instanceof Error ? ex.message : String(ex));
}
if (cancelled) break;
await new Promise<void>((resolve) => {
timer = window.setTimeout(() => resolve(), retryMs);
});
retryMs = Math.min(retryMs * 2, 15000);
}
}
void runStream();
return () => {
cancelled = true;
ac.abort();
if (timer != null) window.clearTimeout(timer);
};
}, [refresh]);
async function act(id: number, action: "start" | "pause" | "update" | "login") {
if (action === "update") {
@@ -158,6 +253,18 @@ export default function MonitorPage() {
<div className="toolbar">
<h2></h2>
<div className="toolbar-actions">
<span
className={`live-pill ${
liveState === "live" ? "ok" : liveState === "retry" ? "bad" : ""
}`}
title="中控 SSE 秒级推送"
>
{liveState === "live"
? `实时 · ${sseSec}s`
: liveState === "retry"
? "重连中…"
: "连接中…"}
</span>
<button type="button" className="btn ghost" onClick={() => void refresh()}>
</button>