push terminal session changes and alert evaluator health

This commit is contained in:
lda committed 2026-10-01 11:32:26 +07:00 Verified
1 parent cf7622e818
commit f7c982cbd7
11 files changed
+663 -101

No files matched your search

+178 -31
View File
@@ -8,6 +8,7 @@ use axum::extract::{Query, State};
use axum::http::StatusCode;
use axum::response::IntoResponse;
use serde::Deserialize;
use tokio::select;
use tokio::sync::{RwLock, broadcast};
use tokio::time::Duration;
use tracing::warn;
@@ -105,15 +106,16 @@ pub struct AlertsSnapshot {
#[derive(Clone)]
pub struct AlertsHub {
tx: broadcast::Sender<String>,
latest_payload: Arc<RwLock<Option<String>>>,
latest_alerts: Arc<RwLock<Vec<AlertState>>>,
evaluator_status: Arc<RwLock<EvaluatorStatus>>,
status: Arc<RwLock<HubStatus>>,
}
#[derive(Default)]
struct EvaluatorStatus {
struct HubStatus {
latest_payload: Option<String>,
latest_alerts: Vec<AlertState>,
last_success_unix: Option<u64>,
failure: Option<String>,
last_health_available: Option<bool>,
}
const ALERT_SNAPSHOT_MAX_AGE_SECS: u64 = 15;
@@ -129,9 +131,7 @@ impl AlertsHub {
let (tx, _) = broadcast::channel(16);
Self {
tx,
latest_payload: Arc::new(RwLock::new(None)),
latest_alerts: Arc::new(RwLock::new(Vec::new())),
evaluator_status: Arc::new(RwLock::new(EvaluatorStatus::default())),
status: Arc::new(RwLock::new(HubStatus::default())),
}
}
@@ -140,27 +140,49 @@ impl AlertsHub {
}
pub async fn latest_alerts(&self) -> Vec<AlertState> {
self.latest_alerts.read().await.clone()
}
pub async fn latest_payload(&self) -> Option<String> {
self.latest_payload.read().await.clone()
self.status.read().await.latest_alerts.clone()
}
async fn mark_unavailable(&self, reason: impl Into<String>) {
self.evaluator_status.write().await.failure = Some(reason.into());
let mut status = self.status.write().await;
if status.failure.is_some() {
return;
}
status.failure = Some(reason.into());
let reason = status.failure.as_deref().unwrap_or_default().to_owned();
status.last_health_available = Some(false);
self.broadcast_health(false, Some(reason));
}
async fn unavailable_reason(&self) -> Option<String> {
let status = self.evaluator_status.read().await;
if let Some(failure) = &status.failure {
return Some(failure.clone());
let status = self.status.read().await;
evaluator_unavailable_reason(&status, now_unix())
}
async fn resync_payloads(&self) -> (String, Option<String>) {
let status = self.status.read().await;
(
encode_health(evaluator_unavailable_reason(&status, now_unix())),
status.latest_payload.clone(),
)
}
async fn broadcast_health_if_changed(&self) {
let mut status = self.status.write().await;
if status.last_success_unix.is_none() || status.failure.is_some() {
return;
}
match status.last_success_unix {
Some(ts) if now_unix().saturating_sub(ts) <= ALERT_SNAPSHOT_MAX_AGE_SECS => None,
Some(_) => Some("alert evaluator snapshot is stale".into()),
None => Some("alert evaluator has not produced a snapshot yet".into()),
let reason = evaluator_unavailable_reason(&status, now_unix());
let available = reason.is_none();
if status.last_health_available == Some(available) {
return;
}
status.last_health_available = Some(available);
self.broadcast_health(available, reason);
}
fn broadcast_health(&self, available: bool, reason: Option<String>) {
let _ = self.tx.send(encode_health_message(available, reason));
}
pub async fn publish(&self, snapshot: AlertsSnapshot) {
@@ -174,16 +196,49 @@ impl AlertsHub {
warn!("failed to encode alerts snapshot payload");
return;
};
*self.latest_alerts.write().await = snapshot.alerts;
*self.latest_payload.write().await = Some(encoded.clone());
let mut status = self.evaluator_status.write().await;
let mut status = self.status.write().await;
let was_unavailable = evaluator_unavailable_reason(&status, now_unix()).is_some();
status.latest_alerts = snapshot.alerts;
status.latest_payload = Some(encoded.clone());
status.last_success_unix = Some(snapshot.ts_unix);
status.failure = None;
drop(status);
let available = evaluator_unavailable_reason(&status, now_unix()).is_none();
if available {
status.last_health_available = Some(true);
}
if was_unavailable && available {
self.broadcast_health(true, None);
}
let _ = self.tx.send(encoded);
}
}
fn evaluator_unavailable_reason(status: &HubStatus, now: u64) -> Option<String> {
if let Some(failure) = &status.failure {
return Some(failure.clone());
}
match status.last_success_unix {
Some(ts) if now.saturating_sub(ts) <= ALERT_SNAPSHOT_MAX_AGE_SECS => None,
Some(_) => Some("alert evaluator snapshot is stale".into()),
None => Some("alert evaluator has not produced a snapshot yet".into()),
}
}
fn encode_health(reason: Option<String>) -> String {
encode_health_message(reason.is_none(), reason)
}
fn encode_health_message(available: bool, reason: Option<String>) -> String {
let mut message = serde_json::json!({
"type": "alerts_health",
"available": available,
});
if let Some(reason) = reason {
message["reason"] = serde_json::Value::String(reason);
}
message.to_string()
}
/// Background task that owns alert evaluation: on a fixed ticker it recomputes
/// the snapshot, persists transitions, and publishes to subscribers so neither
/// the GET endpoint nor individual WS clients evaluate alerts themselves.
@@ -271,22 +326,49 @@ pub async fn alerts_stream(
async fn alerts_stream_socket(state: AppState, mut socket: WebSocket) {
let mut rx = state.alerts.subscribe();
let mut freshness_tick = tokio::time::interval(Duration::from_secs(1));
freshness_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
if let Some(encoded) = state.alerts.latest_payload().await
let (health, snapshot) = state.alerts.resync_payloads().await;
if socket.send(Message::Text(health.into())).await.is_err() {
return;
}
if let Some(encoded) = snapshot
&& socket.send(Message::Text(encoded.into())).await.is_err()
{
return;
}
loop {
match rx.recv().await {
Ok(encoded) => {
if socket.send(Message::Text(encoded.into())).await.is_err() {
break;
select! {
incoming = socket.recv() => {
match incoming {
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
Some(Ok(_)) => {}
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
update = rx.recv() => {
match update {
Ok(encoded) => {
if socket.send(Message::Text(encoded.into())).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(_)) => {
let (health, snapshot) = state.alerts.resync_payloads().await;
if socket.send(Message::Text(health.into())).await.is_err() {
break;
}
if let Some(encoded) = snapshot
&& socket.send(Message::Text(encoded.into())).await.is_err()
{
break;
}
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
_ = freshness_tick.tick() => state.alerts.broadcast_health_if_changed().await,
}
}
}
@@ -648,12 +730,24 @@ mod tests {
async fn alert_hub_reports_initial_failure_and_recovers_on_snapshot() {
let hub = AlertsHub::new();
assert!(hub.unavailable_reason().await.is_some());
let mut updates = hub.subscribe();
hub.mark_unavailable("store query failed").await;
assert_eq!(
hub.unavailable_reason().await.as_deref(),
Some("store query failed")
);
let failure: serde_json::Value =
serde_json::from_str(&updates.try_recv().unwrap()).unwrap();
assert_eq!(failure["type"], "alerts_health");
assert_eq!(failure["available"], false);
assert_eq!(failure["reason"], "store query failed");
hub.mark_unavailable("same outage, later attempt").await;
assert!(
updates.try_recv().is_err(),
"failure notifications deduplicate"
);
hub.publish(AlertsSnapshot {
ts_unix: super::now_unix(),
@@ -662,6 +756,14 @@ mod tests {
})
.await;
assert_eq!(hub.unavailable_reason().await, None);
let recovery: serde_json::Value =
serde_json::from_str(&updates.try_recv().unwrap()).unwrap();
assert_eq!(recovery["type"], "alerts_health");
assert_eq!(recovery["available"], true);
assert_eq!(recovery.get("reason"), None);
let snapshot: serde_json::Value =
serde_json::from_str(&updates.try_recv().unwrap()).unwrap();
assert_eq!(snapshot["type"], "alerts_snapshot");
}
#[tokio::test]
@@ -678,4 +780,49 @@ mod tests {
Some("alert evaluator snapshot is stale")
);
}
#[tokio::test]
async fn alert_hub_initial_resync_marks_cached_snapshot_stale() {
let hub = AlertsHub::new();
let (initial_health, initial_snapshot) = hub.resync_payloads().await;
let initial_health: serde_json::Value = serde_json::from_str(&initial_health).unwrap();
assert_eq!(initial_health["available"], false);
assert_eq!(
initial_health["reason"],
"alert evaluator has not produced a snapshot yet"
);
assert!(initial_snapshot.is_none());
hub.publish(AlertsSnapshot {
ts_unix: 0,
alerts: Vec::new(),
recent_transitions: Vec::new(),
})
.await;
let (stale_health, cached_snapshot) = hub.resync_payloads().await;
let stale_health: serde_json::Value = serde_json::from_str(&stale_health).unwrap();
assert_eq!(stale_health["available"], false);
assert_eq!(stale_health["reason"], "alert evaluator snapshot is stale");
assert!(cached_snapshot.is_some());
}
#[tokio::test]
async fn alert_hub_broadcasts_stale_transition_once() {
let hub = AlertsHub::new();
let mut updates = hub.subscribe();
hub.publish(AlertsSnapshot {
ts_unix: 0,
alerts: Vec::new(),
recent_transitions: Vec::new(),
})
.await;
let _snapshot = updates.try_recv().unwrap();
hub.broadcast_health_if_changed().await;
let stale: serde_json::Value = serde_json::from_str(&updates.try_recv().unwrap()).unwrap();
assert_eq!(stale["available"], false);
assert_eq!(stale["reason"], "alert evaluator snapshot is stale");
hub.broadcast_health_if_changed().await;
assert!(updates.try_recv().is_err());
}
}
+1 -1
View File
@@ -19,7 +19,7 @@ pub use control::{
};
pub use terminals::{
agent_terminal_ws, attach_terminal, close_terminal, create_terminal, get_terminal,
list_terminals, operator_terminal_ws,
list_terminals, operator_terminal_ws, terminal_events_ws,
};
use axum::response::{IntoResponse, Response};
+44
View File
@@ -6,6 +6,7 @@ use axum::response::Response;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use std::time::Duration;
use tokio::sync::broadcast;
use tracing::{info, warn};
use wakey_agent::protocol::{
AgentCapability, DEFAULT_TERMINAL_MAX_SESSIONS, DEFAULT_TERMINAL_SESSION_TTL_SECONDS,
@@ -244,6 +245,49 @@ pub async fn operator_terminal_ws(
.on_upgrade(move |socket| handle_operator_terminal_socket(state, terminal_id, socket))
}
pub async fn terminal_events_ws(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
ws.on_upgrade(move |socket| handle_terminal_events_socket(state, socket))
}
async fn handle_terminal_events_socket(state: AppState, socket: WebSocket) {
let mut events = state.terminals.subscribe();
let (mut write, mut read) = socket.split();
if write
.send(Message::Text(r#"{"type":"sessions_changed"}"#.into()))
.await
.is_err()
{
return;
}
loop {
tokio::select! {
event = events.recv() => {
match event {
Ok(()) | Err(broadcast::error::RecvError::Lagged(_)) => {
if write.send(Message::Text(r#"{"type":"sessions_changed"}"#.into())).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
incoming = read.next() => {
match incoming {
Some(Ok(Message::Ping(payload))) => {
if write.send(Message::Pong(payload)).await.is_err() {
break;
}
}
Some(Ok(Message::Pong(_))) => {}
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
Some(Ok(Message::Text(_))) | Some(Ok(Message::Binary(_))) => {}
}
}
}
}
}
async fn handle_agent_terminal_socket(state: AppState, terminal_id: String, mut socket: WebSocket) {
let auth = match receive_text_handshake(&mut socket).await.and_then(|text| {
serde_json::from_str::<TerminalAgentHandshake>(&text).map_err(|_| "invalid handshake")