use std::collections::{HashMap, VecDeque};
use std::io::{BufRead, BufReader, Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::{json, Value};
use tokio::sync::broadcast;
use crate::queue::SnerdQueue;
struct ProgressEvent {
ts: f64,
task_id: String,
data: String,
}
type ProgressRing = Arc<Mutex<VecDeque<ProgressEvent>>>;
const RING_CAP: usize = 500;
fn now_secs() -> f64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
fn read_deduped_tasks(tasks_path: &str) -> HashMap<String, Value> {
let mut tasks_map: HashMap<String, Value> = HashMap::new();
let file = match std::fs::File::open(tasks_path) {
Ok(f) => f,
Err(_) => return tasks_map,
};
for line in BufReader::new(file).lines().map_while(Result::ok) {
if line.trim().is_empty() {
continue;
}
if let Ok(t) = serde_json::from_str::<Value>(&line) {
if let Some(tid) = t.get("taskId").and_then(|v| v.as_str()) {
tasks_map.insert(tid.to_string(), t);
}
}
}
tasks_map
}
fn has_job_error(t: &Value) -> bool {
t.get("LastJobError").is_some() || t.get("lastJobError").is_some()
}
fn dashboard_status(t: &Value) -> &'static str {
let has_err = has_job_error(t);
let deleted = t
.get("deletedAt")
.map_or(false, |v| !v.is_null());
if deleted {
let retry_count = t.get("retryCount").and_then(|v| v.as_i64()).unwrap_or(0);
let max_retries = t.get("maxRetries").and_then(|v| v.as_i64()).unwrap_or(0);
if has_err && retry_count >= max_retries {
return "dead_letter";
} else if has_err {
return "failed";
}
return "completed";
}
if has_err {
return "failed";
}
if let Some(exec_at) = t.get("executeAt").and_then(|v| v.as_str()) {
if let Ok(et) = chrono::DateTime::parse_from_rfc3339(exec_at) {
if et <= chrono::Utc::now() {
return "active";
}
}
}
"queued"
}
fn stats_body(tasks_path: &str) -> String {
let tasks_map = read_deduped_tasks(tasks_path);
let enqueued = tasks_map.len();
let mut processed = 0usize;
let mut failed = 0usize;
for t in tasks_map.values() {
let deleted = t.get("deletedAt").map_or(false, |v| !v.is_null());
if deleted {
if has_job_error(t) {
failed += 1;
} else {
processed += 1;
}
}
}
format!(
"{{\"enqueued\":{},\"processed\":{},\"failed\":{}}}",
enqueued, processed, failed
)
}
fn tasks_body(tasks_path: &str) -> String {
let tasks_map = read_deduped_tasks(tasks_path);
let res: Vec<Value> = tasks_map
.values()
.map(|t| {
json!({
"id": t.get("taskId"),
"type": t.get("taskType"),
"status": dashboard_status(t),
"progress": 0,
"retryCount": t.get("retryCount").and_then(|v| v.as_i64()).unwrap_or(0),
"maxRetries": t.get("maxRetries").and_then(|v| v.as_i64()).unwrap_or(0),
"retryAfterTime": t.get("retryAfterTime").and_then(|v| v.as_str()).unwrap_or(""),
"cronExpression": t.get("cronExpression"),
"webhookUrl": t.get("webhookUrl"),
"maxExecutionSeconds": t.get("maxExecutionSeconds"),
})
})
.collect();
serde_json::to_string(&res).unwrap_or_else(|_| "[]".to_string())
}
fn progress_body(ring: &ProgressRing) -> String {
let events: Vec<Value> = {
let r = ring.lock().unwrap();
let skip = r.len().saturating_sub(100);
r.iter()
.skip(skip)
.map(|ev| {
json!({
"ts": ev.ts,
"task_id": ev.task_id,
"data": ev.data,
})
})
.collect()
};
serde_json::to_string(&events).unwrap_or_else(|_| "[]".to_string())
}
fn handle_connection(mut stream: TcpStream, tasks_path: &str, ring: &ProgressRing) {
let mut buf = [0u8; 4096];
let n = match stream.read(&mut buf) {
Ok(n) if n > 0 => n,
_ => return,
};
let req = String::from_utf8_lossy(&buf[..n]);
let first_line = req.lines().next().unwrap_or("");
let mut parts = first_line.split_whitespace();
let method = parts.next().unwrap_or("");
let target = parts.next().unwrap_or("/");
let path = target.split('?').next().unwrap_or("/");
let (status, content_type, body) = if method != "GET" {
(
"405 Method Not Allowed",
"text/plain",
"Method Not Allowed".to_string(),
)
} else {
match path {
"/api/stats" => ("200 OK", "application/json", stats_body(tasks_path)),
"/api/tasks" => ("200 OK", "application/json", tasks_body(tasks_path)),
"/api/progress" => ("200 OK", "application/json", progress_body(ring)),
"/" => match std::fs::read_to_string("static/index.html") {
Ok(html) => ("200 OK", "text/html", html),
Err(_) => (
"404 Not Found",
"text/plain",
"Dashboard UI not found: place the dashboard bundle at ./static/index.html"
.to_string(),
),
},
_ => ("404 Not Found", "text/plain", "Not Found".to_string()),
}
};
let resp = format!(
"HTTP/1.1 {}\r\nContent-Type: {}\r\nAccess-Control-Allow-Origin: *\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
status,
content_type,
body.len(),
body
);
let _ = stream.write_all(resp.as_bytes());
}
impl SnerdQueue {
pub fn start_dashboard(&self, port: u16) {
let ring: ProgressRing = Arc::new(Mutex::new(VecDeque::with_capacity(RING_CAP)));
let mut rx = self.subscribe_progress();
let feeder_ring = Arc::clone(&ring);
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_time()
.build()
{
Ok(rt) => rt,
Err(_) => return,
};
rt.block_on(async move {
loop {
match rx.recv().await {
Ok(msg) => {
let mut r = feeder_ring.lock().unwrap();
r.push_back(ProgressEvent {
ts: now_secs(),
task_id: msg.task_id,
data: msg.data,
});
if r.len() > RING_CAP {
r.pop_front();
}
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
});
let tasks_path = self.file_store.file_path().to_string_lossy().to_string();
let listener = match TcpListener::bind(format!("0.0.0.0:{}", port)) {
Ok(l) => l,
Err(e) => {
println!("[Snerd] Failed to start dashboard on port {}: {}", port, e);
return;
}
};
println!("[Snerd] Dashboard running on http://localhost:{}", port);
std::thread::spawn(move || {
for stream in listener.incoming() {
if let Ok(stream) = stream {
let path = tasks_path.clone();
let r = Arc::clone(&ring);
std::thread::spawn(move || handle_connection(stream, &path, &r));
}
}
});
}
}