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.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
@@ -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")
|
||||||
|
|||||||
Reference in new issue
Block a user