use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::sse::{Event, KeepAlive, Sse},
Json,
};
use serde::{Deserialize, Serialize};
use std::convert::Infallible;
use crate::web::db::SessionFilter;
use crate::web::state::WebState;
#[derive(Deserialize)]
pub struct ListSessionsQuery {
pub limit: Option<i64>,
pub cursor: Option<String>,
pub project: Option<String>,
pub exclude_project: Option<String>,
pub machine: Option<String>,
pub agent: Option<String>,
pub date: Option<String>,
pub date_from: Option<String>,
pub date_to: Option<String>,
pub active_since: Option<String>,
pub min_messages: Option<i64>,
pub max_messages: Option<i64>,
pub min_user_messages: Option<i64>,
pub include_one_shot: Option<String>,
pub include_children: Option<String>,
}
#[derive(Serialize)]
pub struct SessionListResponse {
pub sessions: Vec<crate::web::db::Session>,
#[serde(skip_serializing_if = "Option::is_none")]
pub next_cursor: Option<String>,
pub total: usize,
}
pub async fn list_sessions(
State(state): State<WebState>,
Query(params): Query<ListSessionsQuery>,
) -> Result<Json<SessionListResponse>, (StatusCode, Json<ErrorResponse>)> {
let limit = params.limit.unwrap_or(200).min(500);
let filter = SessionFilter {
project: params.project,
exclude_project: params.exclude_project,
machine: params
.machine
.map(|m| m.split(',').map(|s| s.to_string()).collect()),
agent: params
.agent
.map(|a| a.split(',').map(|s| s.to_string()).collect()),
date: params.date,
date_from: params.date_from,
date_to: params.date_to,
active_since: params.active_since,
min_messages: params.min_messages,
max_messages: params.max_messages,
min_user_messages: params.min_user_messages,
include_one_shot: params.include_one_shot.as_deref() == Some("true"),
include_children: params.include_children.as_deref() == Some("true"),
};
let cursor = params.cursor.as_ref().and_then(|c| {
let parts: Vec<&str> = c.splitn(2, '|').collect();
if parts.len() == 2 {
Some((parts[0], parts[1]))
} else {
None
}
});
let sessions = state
.db
.list_sessions(&filter, limit + 1, cursor)
.await
.map_err(|e| internal_error(&e.to_string()))?;
let has_more = sessions.len() as i64 > limit;
let sessions: Vec<_> = sessions.into_iter().take(limit as usize).collect();
let total = sessions.len();
let next_cursor = if has_more {
sessions
.last()
.map(|s| format!("{}|{}", s.ended_at.as_deref().unwrap_or(""), s.id))
} else {
None
};
Ok(Json(SessionListResponse {
sessions,
next_cursor,
total,
}))
}
pub async fn get_session(
State(state): State<WebState>,
Path(id): Path<String>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let session = state
.db
.get_session(&id)
.await
.map_err(|e| internal_error(&e.to_string()))?;
match session {
Some(s) => {
let is_starred = state.db.is_starred(&id).await.unwrap_or(false);
let mut val = serde_json::to_value(&s).unwrap();
val.as_object_mut()
.unwrap()
.insert("is_starred".to_string(), serde_json::json!(is_starred));
Ok(Json(val))
}
None => Err(not_found("session not found")),
}
}
#[derive(Deserialize)]
pub struct MessagesQuery {
pub limit: Option<i64>,
pub from: Option<i64>,
pub direction: Option<String>,
}
#[derive(Serialize)]
pub struct MessagesResponse {
pub messages: Vec<crate::web::db::Message>,
pub count: usize,
}
pub async fn get_messages(
State(state): State<WebState>,
Path(id): Path<String>,
Query(params): Query<MessagesQuery>,
) -> Result<Json<MessagesResponse>, (StatusCode, Json<ErrorResponse>)> {
let limit = params.limit.unwrap_or(100).min(1000);
let desc = params.direction.as_deref() == Some("desc");
let from = params.from.unwrap_or(if desc { i64::MAX } else { 0 });
let messages = state
.db
.get_messages(&id, limit, from, desc)
.await
.map_err(|e| internal_error(&e.to_string()))?;
let count = messages.len();
Ok(Json(MessagesResponse { messages, count }))
}
pub async fn get_children(
State(state): State<WebState>,
Path(id): Path<String>,
) -> Result<Json<Vec<crate::web::db::Session>>, (StatusCode, Json<ErrorResponse>)> {
let children = state
.db
.get_children(&id)
.await
.map_err(|e| internal_error(&e.to_string()))?;
Ok(Json(children))
}
#[derive(Deserialize)]
pub struct MinimapQuery {
pub from: Option<i64>,
pub max: Option<i64>,
}
#[derive(Serialize)]
pub struct MinimapResponse {
pub entries: Vec<crate::web::db::MinimapEntry>,
pub count: usize,
}
pub async fn get_minimap(
State(state): State<WebState>,
Path(id): Path<String>,
Query(params): Query<MinimapQuery>,
) -> Result<Json<MinimapResponse>, (StatusCode, Json<ErrorResponse>)> {
let entries = state
.db
.get_minimap(&id, params.from.unwrap_or(0), params.max)
.await
.map_err(|e| internal_error(&e.to_string()))?;
let count = entries.len();
Ok(Json(MinimapResponse { entries, count }))
}
#[derive(Deserialize)]
pub struct SessionSearchQuery {
pub q: String,
}
pub async fn search_session(
State(state): State<WebState>,
Path(id): Path<String>,
Query(params): Query<SessionSearchQuery>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let ordinals = state
.db
.search_session(&id, ¶ms.q)
.await
.map_err(|e| internal_error(&e.to_string()))?;
Ok(Json(serde_json::json!({ "ordinals": ordinals })))
}
pub async fn watch_session(
State(state): State<WebState>,
Path(id): Path<String>,
) -> Sse<impl futures::Stream<Item = Result<Event, Infallible>>> {
let stream = async_stream::stream! {
let mut last_count: i64 = 0;
let mut last_mtime: i64 = 0;
let mut heartbeat_counter: u32 = 0;
if let Ok(Some((count, mtime))) = state.db.get_session_version(&id).await {
last_count = count;
last_mtime = mtime;
}
loop {
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
heartbeat_counter += 1;
match state.db.get_session_version(&id).await {
Ok(Some((count, mtime))) => {
if count != last_count || mtime != last_mtime {
last_count = count;
last_mtime = mtime;
yield Ok(Event::default()
.event("session_updated")
.data(id.clone()));
}
}
Ok(None) => {
break;
}
Err(_) => {}
}
if heartbeat_counter >= 20 {
heartbeat_counter = 0;
yield Ok(Event::default()
.event("heartbeat")
.data(chrono::Utc::now().to_rfc3339()));
}
}
};
Sse::new(stream).keep_alive(KeepAlive::default())
}
#[derive(Deserialize)]
pub struct RenameRequest {
pub display_name: Option<String>,
}
pub async fn rename_session(
State(state): State<WebState>,
Path(id): Path<String>,
Json(body): Json<RenameRequest>,
) -> Result<Json<crate::web::db::Session>, (StatusCode, Json<ErrorResponse>)> {
state
.db
.rename_session(&id, body.display_name.as_deref())
.await
.map_err(|e| internal_error(&e.to_string()))?;
let session = state
.db
.get_session(&id)
.await
.map_err(|e| internal_error(&e.to_string()))?;
match session {
Some(s) => Ok(Json(s)),
None => Err(not_found("session not found")),
}
}
pub async fn delete_session(
State(state): State<WebState>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
let deleted = state
.db
.soft_delete_session(&id)
.await
.map_err(|e| internal_error(&e.to_string()))?;
if deleted {
Ok(StatusCode::NO_CONTENT)
} else {
Err(not_found("session not found"))
}
}
pub async fn restore_session(
State(state): State<WebState>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
let restored = state
.db
.restore_session(&id)
.await
.map_err(|e| internal_error(&e.to_string()))?;
if restored {
Ok(StatusCode::NO_CONTENT)
} else {
Err(not_found("session not found or not in trash"))
}
}
pub async fn permanent_delete(
State(state): State<WebState>,
Path(id): Path<String>,
) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> {
let deleted = state
.db
.permanent_delete(&id)
.await
.map_err(|e| internal_error(&e.to_string()))?;
if deleted {
Ok(StatusCode::NO_CONTENT)
} else {
Err((
StatusCode::CONFLICT,
Json(ErrorResponse {
error: "session not found or not in trash".to_string(),
}),
))
}
}
#[derive(Deserialize)]
pub struct OneShot {
pub include_one_shot: Option<String>,
}
pub async fn list_projects(
State(state): State<WebState>,
Query(params): Query<OneShot>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let include = params.include_one_shot.as_deref() == Some("true");
let projects = state
.db
.list_projects(include)
.await
.map_err(|e| internal_error(&e.to_string()))?;
Ok(Json(serde_json::json!({ "projects": projects })))
}
pub async fn list_machines(
State(state): State<WebState>,
Query(params): Query<OneShot>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let include = params.include_one_shot.as_deref() == Some("true");
let machines = state
.db
.list_machines(include)
.await
.map_err(|e| internal_error(&e.to_string()))?;
Ok(Json(serde_json::json!({ "machines": machines })))
}
pub async fn list_agents(
State(state): State<WebState>,
Query(params): Query<OneShot>,
) -> Result<Json<serde_json::Value>, (StatusCode, Json<ErrorResponse>)> {
let include = params.include_one_shot.as_deref() == Some("true");
let agents = state
.db
.list_agents(include)
.await
.map_err(|e| internal_error(&e.to_string()))?;
Ok(Json(serde_json::json!({ "agents": agents })))
}
pub async fn get_stats(
State(state): State<WebState>,
Query(params): Query<OneShot>,
) -> Result<Json<crate::web::db::Stats>, (StatusCode, Json<ErrorResponse>)> {
let include = params.include_one_shot.as_deref() == Some("true");
let stats = state
.db
.get_stats(include)
.await
.map_err(|e| internal_error(&e.to_string()))?;
Ok(Json(stats))
}
#[derive(Serialize, Debug)]
pub struct ErrorResponse {
pub error: String,
}
fn internal_error(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}
fn not_found(msg: &str) -> (StatusCode, Json<ErrorResponse>) {
(
StatusCode::NOT_FOUND,
Json(ErrorResponse {
error: msg.to_string(),
}),
)
}