feat: LLM 服务可用性指示灯 (/llm/status 健康探测)

This commit is contained in:
akn committed 2026-09-29 21:39:09 +09:00
1 parent ed5d0f228a
commit 8df639ac4b
2 files changed
+120 -1

No files matched your search

+30
View File
@@ -144,6 +144,9 @@ h1 {
} }
.status-dot.connected { background: #a6e3a1; } .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 { .legend {
display: flex; display: flex;
@@ -175,6 +178,9 @@ h1 {
<div class="status"> <div class="status">
<span class="status-dot" id="statusDot"></span> <span class="status-dot" id="statusDot"></span>
<span id="statusText">connecting...</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>
<div class="legend"> <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(); loadHistory();
connectSSE(); connectSSE();
pollLlm();
setInterval(pollLlm, 15000);
</script> </script>
</body> </body>
+90 -1
View File
@@ -15,6 +15,9 @@ use sqlx::sqlite::SqlitePoolOptions;
use sqlx::SqlitePool; use sqlx::SqlitePool;
use std::convert::Infallible; use std::convert::Infallible;
use std::sync::Arc; use std::sync::Arc;
use std::sync::RwLock;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tokio_stream::StreamExt; use tokio_stream::StreamExt;
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::BroadcastStream;
@@ -33,10 +36,80 @@ struct Notification {
created_at: String, created_at: String,
} }
#[derive(Clone, Serialize, Default)]
struct LlmStatus {
online: bool,
checked_at: Option<String>,
latency_ms: Option<u128>,
}
#[derive(Clone)] #[derive(Clone)]
struct AppState { struct AppState {
pool: SqlitePool, pool: SqlitePool,
tx: broadcast::Sender<String>, 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<()> { 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) 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( async fn handle_events(
State(state): State<Arc<AppState>>, State(state): State<Arc<AppState>>,
) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> { ) -> Sse<impl tokio_stream::Stream<Item = Result<Event, Infallible>>> {
@@ -215,12 +293,23 @@ async fn main() {
run_mqtt(pool.clone(), tx.clone()).await; 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() let app = Router::new()
.route("/", get(handle_root)) .route("/", get(handle_root))
.route("/events", get(handle_events)) .route("/events", get(handle_events))
.route("/history", get(handle_history)) .route("/history", get(handle_history))
.route("/llm/status", get(handle_llm_status))
.with_state(state); .with_state(state);
let port: u16 = std::env::var("NEWSBOARD_PORT") let port: u16 = std::env::var("NEWSBOARD_PORT")