Add SSE second-level status push for control monitor.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,6 +6,9 @@ CONTROL_AUTH_SECRET=change-me-control-secret-please
|
|||||||
CONTROL_AUTH_TOKEN_VERSION=1
|
CONTROL_AUTH_TOKEN_VERSION=1
|
||||||
CONTROL_TOKEN_TTL_SEC=604800
|
CONTROL_TOKEN_TTL_SEC=604800
|
||||||
CONTROL_POLL_INTERVAL_SEC=8
|
CONTROL_POLL_INTERVAL_SEC=8
|
||||||
|
# 监控区 SSE 推送间隔(秒)与单机探测超时
|
||||||
|
CONTROL_SSE_INTERVAL_SEC=1
|
||||||
|
CONTROL_SSE_PROBE_TIMEOUT_SEC=2.5
|
||||||
CONTROL_HTTP_TIMEOUT_SEC=12
|
CONTROL_HTTP_TIMEOUT_SEC=12
|
||||||
# 局域网免登录:1=开启(仅私网 IP),0=关闭
|
# 局域网免登录:1=开启(仅私网 IP),0=关闭
|
||||||
CONTROL_LAN_AUTH_BYPASS=0
|
CONTROL_LAN_AUTH_BYPASS=0
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ async def me(
|
|||||||
return {
|
return {
|
||||||
"username": username,
|
"username": username,
|
||||||
"poll_interval_sec": settings.control_poll_interval_sec,
|
"poll_interval_sec": settings.control_poll_interval_sec,
|
||||||
|
"sse_interval_sec": settings.control_sse_interval_sec,
|
||||||
"show_default_hint": settings.is_default_credentials,
|
"show_default_hint": settings.is_default_credentials,
|
||||||
"lan_bypass_enabled": settings.lan_auth_bypass,
|
"lan_bypass_enabled": settings.lan_auth_bypass,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
import secrets
|
import secrets
|
||||||
from typing import Annotated, Any
|
import time
|
||||||
|
from typing import Annotated, Any, AsyncIterator
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
from ..auth import require_control_user
|
from ..auth import require_control_user
|
||||||
@@ -47,6 +51,50 @@ def _raise_node_error(code: int, data: Any) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _health_looks_like_fleet(health: Any) -> bool:
|
||||||
|
return isinstance(health, dict) and "strategy" in health and "mode" in health
|
||||||
|
|
||||||
|
|
||||||
|
async def _collect_one_status(
|
||||||
|
node: dict[str, Any],
|
||||||
|
*,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
probe = await probe_health(node, timeout=timeout)
|
||||||
|
item: dict[str, Any] = {**_public_node(node), **probe}
|
||||||
|
if probe.get("online") and node.get("token_sealed"):
|
||||||
|
health = probe.get("health")
|
||||||
|
# probe_health 在 /health 失败时可能已用 fleet/status 回填到 health
|
||||||
|
if _health_looks_like_fleet(health):
|
||||||
|
code, data = 200, health
|
||||||
|
else:
|
||||||
|
code, data = await call_node(
|
||||||
|
node, "GET", "/api/fleet/status", timeout=timeout
|
||||||
|
)
|
||||||
|
if code == 200 and isinstance(data, dict):
|
||||||
|
item["fleet"] = data
|
||||||
|
item["fleet_ok"] = True
|
||||||
|
else:
|
||||||
|
item["fleet_ok"] = False
|
||||||
|
item["fleet_error"] = _http_detail(data) if data else f"HTTP {code}"
|
||||||
|
elif not node.get("token_sealed"):
|
||||||
|
item["fleet_ok"] = False
|
||||||
|
item["fleet_error"] = "未生成 Token"
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
async def collect_all_status(*, timeout: float | None = None) -> list[dict[str, Any]]:
|
||||||
|
db = get_control_db()
|
||||||
|
nodes = db.list_nodes()
|
||||||
|
if not nodes:
|
||||||
|
return []
|
||||||
|
return list(
|
||||||
|
await asyncio.gather(
|
||||||
|
*[_collect_one_status(n, timeout=timeout) for n in nodes]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class NodeCreate(BaseModel):
|
class NodeCreate(BaseModel):
|
||||||
name: str = Field(min_length=1, max_length=64)
|
name: str = Field(min_length=1, max_length=64)
|
||||||
base_url: str = Field(min_length=8, max_length=256)
|
base_url: str = Field(min_length=8, max_length=256)
|
||||||
@@ -79,26 +127,65 @@ async def create_node(
|
|||||||
|
|
||||||
@router.get("/status/all")
|
@router.get("/status/all")
|
||||||
async def status_all(_user: Annotated[str, Depends(require_control_user)]) -> dict:
|
async def status_all(_user: Annotated[str, Depends(require_control_user)]) -> dict:
|
||||||
db = get_control_db()
|
items = await collect_all_status()
|
||||||
items = []
|
|
||||||
for node in db.list_nodes():
|
|
||||||
probe = await probe_health(node)
|
|
||||||
item = {**_public_node(node), **probe}
|
|
||||||
if probe.get("online") and node.get("token_sealed"):
|
|
||||||
code, data = await call_node(node, "GET", "/api/fleet/status")
|
|
||||||
if code == 200 and isinstance(data, dict):
|
|
||||||
item["fleet"] = data
|
|
||||||
item["fleet_ok"] = True
|
|
||||||
else:
|
|
||||||
item["fleet_ok"] = False
|
|
||||||
item["fleet_error"] = _http_detail(data) if data else f"HTTP {code}"
|
|
||||||
elif not node.get("token_sealed"):
|
|
||||||
item["fleet_ok"] = False
|
|
||||||
item["fleet_error"] = "未生成 Token"
|
|
||||||
items.append(item)
|
|
||||||
return {"nodes": items}
|
return {"nodes": items}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/status/stream")
|
||||||
|
async def status_stream(
|
||||||
|
request: Request,
|
||||||
|
_user: Annotated[str, Depends(require_control_user)],
|
||||||
|
) -> StreamingResponse:
|
||||||
|
"""SSE:中控约每秒并行拉取策略机状态并推送到浏览器。"""
|
||||||
|
settings = get_control_settings()
|
||||||
|
interval = max(0.5, float(settings.control_sse_interval_sec))
|
||||||
|
probe_timeout = max(0.5, float(settings.control_sse_probe_timeout_sec))
|
||||||
|
|
||||||
|
async def event_gen() -> AsyncIterator[str]:
|
||||||
|
last_payload = ""
|
||||||
|
last_heartbeat = 0.0
|
||||||
|
yield f": connected interval={interval}\n\n"
|
||||||
|
while True:
|
||||||
|
if await request.is_disconnected():
|
||||||
|
break
|
||||||
|
started = time.monotonic()
|
||||||
|
try:
|
||||||
|
items = await collect_all_status(timeout=probe_timeout)
|
||||||
|
payload = json.dumps(
|
||||||
|
{"nodes": items, "ts_ms": int(time.time() * 1000)},
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
now = time.monotonic()
|
||||||
|
if payload != last_payload:
|
||||||
|
last_payload = payload
|
||||||
|
yield f"event: nodes\ndata: {payload}\n\n"
|
||||||
|
last_heartbeat = now
|
||||||
|
elif now - last_heartbeat >= 5.0:
|
||||||
|
yield f": heartbeat {int(time.time())}\n\n"
|
||||||
|
last_heartbeat = now
|
||||||
|
except Exception as e:
|
||||||
|
err = json.dumps(
|
||||||
|
{"detail": str(e)},
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
yield f"event: error\ndata: {err}\n\n"
|
||||||
|
elapsed = time.monotonic() - started
|
||||||
|
await asyncio.sleep(max(0.05, interval - elapsed))
|
||||||
|
|
||||||
|
return StreamingResponse(
|
||||||
|
event_gen(),
|
||||||
|
media_type="text/event-stream",
|
||||||
|
headers={
|
||||||
|
"Cache-Control": "no-cache",
|
||||||
|
"Connection": "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/update-batch")
|
@router.post("/update-batch")
|
||||||
async def update_batch(
|
async def update_batch(
|
||||||
body: dict,
|
body: dict,
|
||||||
@@ -179,13 +266,7 @@ async def node_status(
|
|||||||
node = db.get_node(node_id)
|
node = db.get_node(node_id)
|
||||||
if not node:
|
if not node:
|
||||||
raise HTTPException(status_code=404, detail="节点不存在")
|
raise HTTPException(status_code=404, detail="节点不存在")
|
||||||
probe = await probe_health(node)
|
return await _collect_one_status(node)
|
||||||
out = {**_public_node(node), **probe}
|
|
||||||
if probe.get("online") and node.get("token_sealed"):
|
|
||||||
code, data = await call_node(node, "GET", "/api/fleet/status")
|
|
||||||
if code == 200 and isinstance(data, dict):
|
|
||||||
out["fleet"] = data
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{node_id}/start")
|
@router.post("/{node_id}/start")
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ class ControlSettings(BaseSettings):
|
|||||||
control_token_ttl_sec: int = 7 * 24 * 3600
|
control_token_ttl_sec: int = 7 * 24 * 3600
|
||||||
control_db_path: str = ""
|
control_db_path: str = ""
|
||||||
control_poll_interval_sec: int = 8
|
control_poll_interval_sec: int = 8
|
||||||
|
control_sse_interval_sec: float = 1.0
|
||||||
|
control_sse_probe_timeout_sec: float = 2.5
|
||||||
control_http_timeout_sec: float = 12.0
|
control_http_timeout_sec: float = 12.0
|
||||||
control_port: int = 5160
|
control_port: int = 5160
|
||||||
# "1"/"0":局域网客户端免密登录
|
# "1"/"0":局域网客户端免密登录
|
||||||
|
|||||||
@@ -71,6 +71,8 @@ _DEPLOY_DEFAULTS: dict[str, str] = {
|
|||||||
"CONTROL_AUTH_SECRET": "change-me-control-secret-please",
|
"CONTROL_AUTH_SECRET": "change-me-control-secret-please",
|
||||||
"CONTROL_TOKEN_TTL_SEC": "604800",
|
"CONTROL_TOKEN_TTL_SEC": "604800",
|
||||||
"CONTROL_POLL_INTERVAL_SEC": "8",
|
"CONTROL_POLL_INTERVAL_SEC": "8",
|
||||||
|
"CONTROL_SSE_INTERVAL_SEC": "1",
|
||||||
|
"CONTROL_SSE_PROBE_TIMEOUT_SEC": "2.5",
|
||||||
"CONTROL_HTTP_TIMEOUT_SEC": "12",
|
"CONTROL_HTTP_TIMEOUT_SEC": "12",
|
||||||
"CONTROL_AUTH_TOKEN_VERSION": "1",
|
"CONTROL_AUTH_TOKEN_VERSION": "1",
|
||||||
"CONTROL_LAN_AUTH_BYPASS": "0",
|
"CONTROL_LAN_AUTH_BYPASS": "0",
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ app.include_router(api_router)
|
|||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health() -> dict:
|
async def health() -> dict:
|
||||||
s = get_control_settings()
|
s = get_control_settings()
|
||||||
return {"ok": True, "app": "control", "poll_interval_sec": s.control_poll_interval_sec}
|
return {"ok": True, "app": "control", "sse_interval_sec": s.control_sse_interval_sec}
|
||||||
|
|
||||||
|
|
||||||
_DIST = resolve_frontend_dist()
|
_DIST = resolve_frontend_dist()
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ async def call_node(
|
|||||||
*,
|
*,
|
||||||
require_token: bool = True,
|
require_token: bool = True,
|
||||||
json_body: dict | None = None,
|
json_body: dict | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
) -> tuple[int, Any]:
|
) -> tuple[int, Any]:
|
||||||
settings = get_control_settings()
|
settings = get_control_settings()
|
||||||
base = str(node["base_url"]).rstrip("/")
|
base = str(node["base_url"]).rstrip("/")
|
||||||
@@ -43,9 +44,9 @@ async def call_node(
|
|||||||
headers["X-Fleet-Token"] = tok
|
headers["X-Fleet-Token"] = tok
|
||||||
headers["Authorization"] = f"Fleet {tok}"
|
headers["Authorization"] = f"Fleet {tok}"
|
||||||
url = f"{base}{path}"
|
url = f"{base}{path}"
|
||||||
timeout = settings.control_http_timeout_sec
|
to = float(timeout if timeout is not None else settings.control_http_timeout_sec)
|
||||||
try:
|
try:
|
||||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=False) as client:
|
async with httpx.AsyncClient(timeout=to, follow_redirects=False) as client:
|
||||||
res = await client.request(method, url, headers=headers, json=json_body)
|
res = await client.request(method, url, headers=headers, json=json_body)
|
||||||
# 少数反代会 301/302 补尾斜杠;手动跟一次并保留 Token 头
|
# 少数反代会 301/302 补尾斜杠;手动跟一次并保留 Token 头
|
||||||
if res.status_code in (301, 302, 307, 308) and res.headers.get("location"):
|
if res.status_code in (301, 302, 307, 308) and res.headers.get("location"):
|
||||||
@@ -64,12 +65,20 @@ async def call_node(
|
|||||||
return 502, {"detail": f"连接失败: {e}"}
|
return 502, {"detail": f"连接失败: {e}"}
|
||||||
|
|
||||||
|
|
||||||
async def probe_health(node: dict[str, Any]) -> dict[str, Any]:
|
async def probe_health(
|
||||||
code, data = await call_node(node, "GET", "/health", require_token=False)
|
node: dict[str, Any],
|
||||||
|
*,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
code, data = await call_node(
|
||||||
|
node, "GET", "/health", require_token=False, timeout=timeout
|
||||||
|
)
|
||||||
if code == 200 and isinstance(data, dict):
|
if code == 200 and isinstance(data, dict):
|
||||||
return {"online": True, "health": data, "error": None}
|
return {"online": True, "health": data, "error": None}
|
||||||
# fallback fleet status if health blocked
|
# fallback fleet status if health blocked
|
||||||
code2, data2 = await call_node(node, "GET", "/api/fleet/status", require_token=True)
|
code2, data2 = await call_node(
|
||||||
|
node, "GET", "/api/fleet/status", require_token=True, timeout=timeout
|
||||||
|
)
|
||||||
if code2 == 200 and isinstance(data2, dict):
|
if code2 == 200 and isinstance(data2, dict):
|
||||||
return {"online": True, "health": data2, "error": None}
|
return {"online": True, "health": data2, "error": None}
|
||||||
detail = ""
|
detail = ""
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { apiFetch, type NodeCard } from "../api";
|
import { apiFetch, clearSession, getToken, type NodeCard } from "../api";
|
||||||
|
|
||||||
function pickStrategy(n: NodeCard) {
|
function pickStrategy(n: NodeCard) {
|
||||||
const fleet = (n.fleet || {}) as Record<string, unknown>;
|
const fleet = (n.fleet || {}) as Record<string, unknown>;
|
||||||
@@ -55,7 +55,8 @@ export default function MonitorPage() {
|
|||||||
const [err, setErr] = useState("");
|
const [err, setErr] = useState("");
|
||||||
const [busy, setBusy] = useState<Record<number, string>>({});
|
const [busy, setBusy] = useState<Record<number, string>>({});
|
||||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
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 [detailId, setDetailId] = useState<number | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
@@ -69,15 +70,109 @@ export default function MonitorPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
apiFetch<{ poll_interval_sec?: number }>("/api/auth/me")
|
apiFetch<{ sse_interval_sec?: number }>("/api/auth/me")
|
||||||
.then((m) => {
|
.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);
|
.catch(() => undefined);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
let retryMs = 1000;
|
||||||
|
let timer: number | undefined;
|
||||||
|
const ac = new AbortController();
|
||||||
|
|
||||||
void refresh();
|
void refresh();
|
||||||
const id = window.setInterval(() => void refresh(), pollSec * 1000);
|
|
||||||
return () => window.clearInterval(id);
|
async function runStream() {
|
||||||
}, [refresh, pollSec]);
|
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") {
|
async function act(id: number, action: "start" | "pause" | "update" | "login") {
|
||||||
if (action === "update") {
|
if (action === "update") {
|
||||||
@@ -158,6 +253,18 @@ export default function MonitorPage() {
|
|||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
<h2>监控区</h2>
|
<h2>监控区</h2>
|
||||||
<div className="toolbar-actions">
|
<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 type="button" className="btn ghost" onClick={() => void refresh()}>
|
||||||
刷新
|
刷新
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -177,6 +177,29 @@ input {
|
|||||||
.toolbar-actions {
|
.toolbar-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-pill {
|
||||||
|
font-size: 0.82rem;
|
||||||
|
color: var(--muted);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-pill.ok {
|
||||||
|
color: var(--pnl-pos);
|
||||||
|
border-color: rgba(46, 229, 154, 0.45);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.live-pill.bad {
|
||||||
|
color: var(--pnl-neg);
|
||||||
|
border-color: rgba(255, 92, 92, 0.45);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-grid {
|
.card-grid {
|
||||||
|
|||||||
+6
-2
@@ -81,7 +81,9 @@ bash /opt/eth_hedge_sim/control/deploy/update.sh
|
|||||||
| `CONTROL_AUTH_USERNAME` / `PASSWORD` | 中控登录 |
|
| `CONTROL_AUTH_USERNAME` / `PASSWORD` | 中控登录 |
|
||||||
| `CONTROL_AUTH_SECRET` | Token 加密与签名密钥(部署自动填默认值,有值不覆盖) |
|
| `CONTROL_AUTH_SECRET` | Token 加密与签名密钥(部署自动填默认值,有值不覆盖) |
|
||||||
| `CONTROL_LAN_AUTH_BYPASS` | `1`=局域网免登录,`0`=关闭 |
|
| `CONTROL_LAN_AUTH_BYPASS` | `1`=局域网免登录,`0`=关闭 |
|
||||||
| `CONTROL_POLL_INTERVAL_SEC` | 监控轮询间隔 |
|
| `CONTROL_SSE_INTERVAL_SEC` | 监控 SSE 推送间隔(默认 1 秒) |
|
||||||
|
| `CONTROL_SSE_PROBE_TIMEOUT_SEC` | 单次探测策略机超时(默认 2.5 秒) |
|
||||||
|
| `CONTROL_POLL_INTERVAL_SEC` | 兼容保留(旧前端轮询;现以 SSE 为主) |
|
||||||
|
|
||||||
**勿提交** `.env.control` 到 git。
|
**勿提交** `.env.control` 到 git。
|
||||||
|
|
||||||
@@ -112,7 +114,9 @@ bash /opt/eth_hedge_sim/control/deploy/update.sh
|
|||||||
|
|
||||||
## 5. 监控区
|
## 5. 监控区
|
||||||
|
|
||||||
- **卡片**:在线/离线、SIM/LIVE、阶段、轮次、行情、Token 状态。
|
- **实时推送**:浏览器通过 SSE(`GET /api/nodes/status/stream`)接收状态;中控约每秒**并行**拉取各策略机 `/api/fleet/status`,有变化才推送;断线自动重连。工具栏显示「实时 · 1s」。
|
||||||
|
- **刷新**:仍可手动走 `/api/nodes/status/all` 拉一次。
|
||||||
|
- **卡片**:在线/离线、SIM/LIVE、阶段、持仓、轮次、行情、Token 状态。
|
||||||
- **运行中**:卡片绿色;底部按钮显示「运行中」且不可点启动。
|
- **运行中**:卡片绿色;底部按钮显示「运行中」且不可点启动。
|
||||||
- **点击卡片**:放大弹层 — 策略详情 + 持仓腿表;**净浮盈 / 浮盈** 正绿负红加粗。
|
- **点击卡片**:放大弹层 — 策略详情 + 持仓腿表;**净浮盈 / 浮盈** 正绿负红加粗。
|
||||||
- **登录策略机**:免密新标签打开策略页。
|
- **登录策略机**:免密新标签打开策略页。
|
||||||
|
|||||||
Reference in New Issue
Block a user