feat: LLM 服务可用性指示灯 (/llm/status 健康探测)
This commit is contained in:
1 parent
ed5d0f228a
commit
8df639ac4b
2 files changed
+120
-1
No files matched your search
@@ -144,6 +144,9 @@ h1 {
|
||||
}
|
||||
|
||||
.status-dot.connected { background: #a6e3a1; }
|
||||
.status-dot.online { background: #a6e3a1; box-shadow: 0 0 6px #a6e3a1; }
|
||||
.status-dot.offline { background: #f38ba8; }
|
||||
.status-sep { color: #45475a; }
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
@@ -175,6 +178,9 @@ h1 {
|
||||
<div class="status">
|
||||
<span class="status-dot" id="statusDot"></span>
|
||||
<span id="statusText">connecting...</span>
|
||||
<span class="status-sep">·</span>
|
||||
<span class="status-dot" id="llmDot"></span>
|
||||
<span id="llmText">LLM ...</span>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
@@ -295,8 +301,32 @@ function connectSSE() {
|
||||
};
|
||||
}
|
||||
|
||||
const llmDot = document.getElementById('llmDot');
|
||||
const llmText = document.getElementById('llmText');
|
||||
|
||||
async function pollLlm() {
|
||||
try {
|
||||
const res = await fetch('/llm/status');
|
||||
const d = await res.json();
|
||||
if (d.online) {
|
||||
llmDot.className = 'status-dot online';
|
||||
llmText.textContent = d.latency_ms != null
|
||||
? `LLM online · ${d.latency_ms}ms`
|
||||
: 'LLM online';
|
||||
} else {
|
||||
llmDot.className = 'status-dot offline';
|
||||
llmText.textContent = 'LLM offline';
|
||||
}
|
||||
} catch {
|
||||
llmDot.className = 'status-dot offline';
|
||||
llmText.textContent = 'LLM unknown';
|
||||
}
|
||||
}
|
||||
|
||||
loadHistory();
|
||||
connectSSE();
|
||||
pollLlm();
|
||||
setInterval(pollLlm, 15000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
+90
-1
@@ -15,6 +15,9 @@ use sqlx::sqlite::SqlitePoolOptions;
|
||||
use sqlx::SqlitePool;
|
||||
use std::convert::Infallible;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::broadcast;
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::BroadcastStream;
|
||||
@@ -33,10 +36,80 @@ struct Notification {
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Default)]
|
||||
struct LlmStatus {
|
||||
online: bool,
|
||||
checked_at: Option<String>,
|
||||
latency_ms: Option<u128>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct AppState {
|
||||
pool: SqlitePool,
|
||||
tx: broadcast::Sender<String>,
|
||||
llm: Arc<RwLock<LlmStatus>>,
|
||||
}
|
||||
|
||||
fn parse_http_url(url: &str) -> Option<(String, u16, String)> {
|
||||
let rest = url.strip_prefix("http://")?;
|
||||
let (authority, path) = match rest.find('/') {
|
||||
Some(i) => (&rest[..i], &rest[i..]),
|
||||
None => (rest, "/"),
|
||||
};
|
||||
let (host, port) = match authority.rsplit_once(':') {
|
||||
Some((h, p)) => (h.to_string(), p.parse().ok()?),
|
||||
None => (authority.to_string(), 80u16),
|
||||
};
|
||||
Some((host, port, path.to_string()))
|
||||
}
|
||||
|
||||
/// 裸 HTTP GET,仅检查状态行是否为 200。
|
||||
/// 避免引入 HTTP client 依赖;目标为受信内网地址。
|
||||
async fn probe_llm(host: &str, port: u16, path: &str) -> bool {
|
||||
let addr = format!("{host}:{port}");
|
||||
let req = format!(
|
||||
"GET {path} HTTP/1.1\r\nHost: {addr}\r\nUser-Agent: newsboard-health\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
let attempt = async {
|
||||
let mut stream = tokio::net::TcpStream::connect(&addr).await?;
|
||||
stream.write_all(req.as_bytes()).await?;
|
||||
let mut buf = [0u8; 64];
|
||||
let n = stream.read(&mut buf).await?;
|
||||
let head = String::from_utf8_lossy(&buf[..n]);
|
||||
Ok::<bool, std::io::Error>(
|
||||
head.starts_with("HTTP/1.1 200") || head.starts_with("HTTP/1.0 200"),
|
||||
)
|
||||
};
|
||||
matches!(
|
||||
tokio::time::timeout(Duration::from_secs(5), attempt).await,
|
||||
Ok(Ok(true))
|
||||
)
|
||||
}
|
||||
|
||||
fn spawn_llm_probe(status: Arc<RwLock<LlmStatus>>, url: String) {
|
||||
tokio::spawn(async move {
|
||||
let (host, port, path) = match parse_http_url(&url) {
|
||||
Some(v) => v,
|
||||
None => {
|
||||
error!("Invalid LLM health URL: {url}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut ticker = tokio::time::interval(Duration::from_secs(15));
|
||||
loop {
|
||||
ticker.tick().await;
|
||||
let start = Instant::now();
|
||||
let online = probe_llm(&host, port, &path).await;
|
||||
let snapshot = LlmStatus {
|
||||
online,
|
||||
checked_at: Some(Utc::now().to_rfc3339()),
|
||||
latency_ms: Some(start.elapsed().as_millis()),
|
||||
};
|
||||
if let Ok(mut s) = status.write() {
|
||||
*s = snapshot;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn init_db(pool: &SqlitePool) -> sqlx::Result<()> {
|
||||
@@ -171,6 +244,11 @@ async fn handle_history(State(state): State<Arc<AppState>>) -> impl IntoResponse
|
||||
axum::Json(rows)
|
||||
}
|
||||
|
||||
async fn handle_llm_status(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
let status = state.llm.read().map(|s| s.clone()).unwrap_or_default();
|
||||
axum::Json(status)
|
||||
}
|
||||
|
||||
async fn handle_events(
|
||||
State(state): State<Arc<AppState>>,
|
||||
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
|
||||
@@ -215,12 +293,23 @@ async fn main() {
|
||||
|
||||
run_mqtt(pool.clone(), tx.clone()).await;
|
||||
|
||||
let state = Arc::new(AppState { pool, tx });
|
||||
let llm_status = Arc::new(RwLock::new(LlmStatus::default()));
|
||||
let llm_health_url = std::env::var("NEWSBOARD_LLM_HEALTH_URL")
|
||||
.unwrap_or_else(|_| "http://192.168.0.4:8090/health".into());
|
||||
info!("LLM health probe target: {llm_health_url}");
|
||||
spawn_llm_probe(llm_status.clone(), llm_health_url);
|
||||
|
||||
let state = Arc::new(AppState {
|
||||
pool,
|
||||
tx,
|
||||
llm: llm_status,
|
||||
});
|
||||
|
||||
let app = Router::new()
|
||||
.route("/", get(handle_root))
|
||||
.route("/events", get(handle_events))
|
||||
.route("/history", get(handle_history))
|
||||
.route("/llm/status", get(handle_llm_status))
|
||||
.with_state(state);
|
||||
|
||||
let port: u16 = std::env::var("NEWSBOARD_PORT")
|
||||
|
||||
Reference in new issue
Block a user