use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::sse::{Event, KeepAlive, Sse},
};
use futures::{Stream, StreamExt};
use serde::{Deserialize, Serialize};
use std::convert::Infallible;
use std::time::Duration;
use tracing::{error, info, warn};
use crate::state::AppState;
#[derive(Debug, Deserialize)]
pub struct LogsQuery {
#[serde(default)]
pub follow: bool,
pub tail: Option<String>,
#[serde(default)]
pub timestamps: bool,
pub region: Option<String>,
#[serde(default)]
pub include_levels: bool,
}
#[derive(Debug, Serialize)]
pub struct LogEntry {
pub timestamp: String,
pub level: String,
pub region: String,
pub machine_id: String,
pub app_name: String,
pub message: String,
pub stream: String,
pub correlation_id: Option<String>,
}
pub async fn stream_machine_logs(
Path((app_name, machine_id)): Path<(String, String)>,
Query(params): Query<LogsQuery>,
State(state): State<AppState>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, StatusCode> {
info!(
app.name = %app_name,
machine.id = %machine_id,
follow = params.follow,
tail = ?params.tail,
region = ?params.region,
"Starting log stream request"
);
let container_id = match state.docker.get_container_id_by_machine(&machine_id).await {
Ok(Some(id)) => id,
Ok(None) => {
warn!(
machine.id = %machine_id,
"No container found for machine"
);
return Err(StatusCode::NOT_FOUND);
}
Err(e) => {
error!(
machine.id = %machine_id,
error = %e,
"Failed to get container for machine"
);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let log_stream = match state.docker.stream_logs(
&container_id,
params.follow,
params.tail,
params.timestamps,
).await {
Ok(stream) => stream,
Err(e) => {
error!(
container.id = %container_id,
machine.id = %machine_id,
error = %e,
"Failed to start log stream"
);
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
};
let region_filter = params.region.clone();
let include_levels = params.include_levels;
let app_name_clone = app_name.clone();
let machine_id_clone = machine_id.clone();
let event_stream = log_stream.map(move |log_result| {
match log_result {
Ok(log_output) => {
let log_entry = process_log_output(
log_output,
&app_name_clone,
&machine_id_clone,
®ion_filter,
include_levels,
);
match serde_json::to_string(&log_entry) {
Ok(json) => Ok(Event::default().data(json)),
Err(e) => {
error!(error = %e, "Failed to serialize log entry");
Ok(Event::default().data(format!(r#"{{"error": "Failed to serialize log: {}"}}"#, e)))
}
}
}
Err(e) => {
error!(error = %e, "Docker log stream error");
Ok(Event::default().data(format!(r#"{{"error": "Log stream error: {}"}}"#, e)))
}
}
});
Ok(Sse::new(event_stream).keep_alive(
KeepAlive::new()
.interval(Duration::from_secs(10))
.text("heartbeat"),
))
}
fn process_log_output(
log_output: bollard::container::LogOutput,
app_name: &str,
machine_id: &str,
_region_filter: &Option<String>,
include_levels: bool,
) -> LogEntry {
use bollard::container::LogOutput;
let (stream_type, message_bytes) = match log_output {
LogOutput::StdOut { message } => ("stdout", message),
LogOutput::StdErr { message } => ("stderr", message),
LogOutput::StdIn { message } => ("stdin", message),
LogOutput::Console { message } => ("console", message),
};
let raw_message = String::from_utf8_lossy(&message_bytes);
let cleaned_message = raw_message.trim_end_matches('\n').trim_end_matches('\r');
let (timestamp, message) = if cleaned_message.len() > 30 && cleaned_message.chars().nth(30) == Some(' ') {
let (ts, msg) = cleaned_message.split_at(30);
(ts.to_string(), msg.trim_start().to_string())
} else {
(chrono::Utc::now().to_rfc3339(), cleaned_message.to_string())
};
let level = if include_levels {
extract_log_level(&message)
} else {
"info".to_string()
};
let correlation_id = Some(uuid::Uuid::new_v4().to_string());
LogEntry {
timestamp,
level,
region: "local".to_string(), machine_id: machine_id.to_string(),
app_name: app_name.to_string(),
message,
stream: stream_type.to_string(),
correlation_id,
}
}
fn extract_log_level(message: &str) -> String {
let lower_msg = message.to_lowercase();
if lower_msg.contains("error") || lower_msg.contains("err") || lower_msg.contains("fail") {
"error".to_string()
} else if lower_msg.contains("warn") || lower_msg.contains("warning") {
"warn".to_string()
} else if lower_msg.contains("debug") || lower_msg.contains("dbg") {
"debug".to_string()
} else if lower_msg.contains("info") || lower_msg.contains("starting") || lower_msg.contains("listening") {
"info".to_string()
} else {
"info".to_string()
}
}
pub async fn get_logs_summary(
Path((app_name, machine_id)): Path<(String, String)>,
State(state): State<AppState>,
) -> Result<axum::Json<LogsSummary>, StatusCode> {
info!(
app.name = %app_name,
machine.id = %machine_id,
"Getting logs summary"
);
let container_id = match state.docker.get_container_id_by_machine(&machine_id).await {
Ok(Some(id)) => id,
Ok(None) => return Err(StatusCode::NOT_FOUND),
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
};
let mut recent_logs = Vec::new();
if let Ok(log_stream) = state.docker.stream_logs(&container_id, false, Some("10".to_string()), true).await {
let mut stream = log_stream.take(10);
while let Some(log_result) = stream.next().await {
if let Ok(log_output) = log_result {
recent_logs.push(log_output);
}
}
}
let summary = LogsSummary {
machine_id: machine_id.clone(),
app_name: app_name.clone(),
container_id,
recent_log_count: recent_logs.len(),
has_errors: recent_logs.iter().any(|log| {
match log {
bollard::container::LogOutput::StdErr { .. } => true,
bollard::container::LogOutput::StdOut { message } => {
String::from_utf8_lossy(message).to_lowercase().contains("error")
}
_ => false,
}
}),
last_activity: chrono::Utc::now().to_rfc3339(),
region: "local".to_string(),
};
Ok(axum::Json(summary))
}
#[derive(Debug, Serialize)]
pub struct LogsSummary {
pub machine_id: String,
pub app_name: String,
pub container_id: String,
pub recent_log_count: usize,
pub has_errors: bool,
pub last_activity: String,
pub region: String,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_log_level() {
assert_eq!(extract_log_level("This is an error message"), "error");
assert_eq!(extract_log_level("WARNING: something happened"), "warn");
assert_eq!(extract_log_level("Debug info here"), "debug");
assert_eq!(extract_log_level("Server starting on port 8080"), "info");
assert_eq!(extract_log_level("Normal message"), "info");
}
#[test]
fn test_log_entry_serialization() {
let entry = LogEntry {
timestamp: "2024-06-22T10:30:00Z".to_string(),
level: "info".to_string(),
region: "local".to_string(),
machine_id: "abc123".to_string(),
app_name: "test-app".to_string(),
message: "Hello world".to_string(),
stream: "stdout".to_string(),
correlation_id: Some("123-456-789".to_string()),
};
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("Hello world"));
assert!(json.contains("abc123"));
}
}