Add SSE second-level status push for control monitor.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user