1use std::io::{Read, Write};
9use std::net::{SocketAddr, TcpListener, TcpStream};
10use std::sync::Arc;
11use std::time::Duration;
12
13use crate::state::{DashboardState, RecordingStatus};
14use crate::web::INDEX_HTML;
15
16#[derive(Debug)]
18pub enum ServerError {
19 Io(std::io::Error),
21}
22
23impl std::fmt::Display for ServerError {
24 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
25 match self {
26 Self::Io(e) => write!(f, "io: {e}"),
27 }
28 }
29}
30
31impl std::error::Error for ServerError {}
32
33pub fn route_with_body(
35 method: &str,
36 path: &str,
37 body: &str,
38 state: &DashboardState,
39) -> (u16, &'static str, String) {
40 if method == "POST" {
41 match path {
42 "/api/inject/topics" => match state.inject_topics_json(body) {
43 Ok(n) => (200, "application/json", format!(r#"{{"injected":{n}}}"#)),
44 Err(e) => (400, "text/plain", format!("inject topics: {e}\n")),
45 },
46 "/api/inject/participants" => match state.inject_participants_json(body) {
47 Ok(n) => (200, "application/json", format!(r#"{{"injected":{n}}}"#)),
48 Err(e) => (400, "text/plain", format!("inject participants: {e}\n")),
49 },
50 "/api/inject/histograms" => match state.inject_histograms_json(body) {
51 Ok(n) => (200, "application/json", format!(r#"{{"injected":{n}}}"#)),
52 Err(e) => (400, "text/plain", format!("inject histograms: {e}\n")),
53 },
54 _ => route(method, path, state),
55 }
56 } else {
57 route(method, path, state)
58 }
59}
60
61pub fn route(method: &str, path: &str, state: &DashboardState) -> (u16, &'static str, String) {
63 match (method, path) {
64 ("GET", "/") | ("GET", "/index.html") => {
65 (200, "text/html; charset=utf-8", INDEX_HTML.into())
66 }
67 ("GET", "/api/topics") => (200, "application/json", state.topics_json()),
68 ("GET", "/api/participants") => (200, "application/json", state.participants_json()),
69 ("GET", "/api/histograms") => (200, "application/json", state.histograms_json()),
70 ("GET", "/api/graph") => (200, "application/json", state.graph_json()),
71 ("GET", "/api/recording") => (200, "application/json", state.recording_json()),
72 ("POST", "/api/recording/toggle") => {
73 let cur_json = state.recording_json();
76 let was_active = cur_json.contains(r#""active":true"#);
77 state.set_recording(RecordingStatus {
78 active: !was_active,
79 output_path: if was_active {
80 None
81 } else {
82 Some("/tmp/zerodds-live.zddsrec".into())
83 },
84 frames: 0,
85 });
86 (200, "application/json", state.recording_json())
87 }
88 _ => (404, "text/plain", "not found\n".into()),
89 }
90}
91
92fn format_response(status: u16, content_type: &str, body: &str) -> String {
94 let reason = match status {
95 200 => "OK",
96 404 => "Not Found",
97 405 => "Method Not Allowed",
98 500 => "Internal Server Error",
99 _ => "OK",
100 };
101 format!(
102 "HTTP/1.1 {status} {reason}\r\n\
103 Content-Type: {content_type}\r\n\
104 Content-Length: {len}\r\n\
105 Cache-Control: no-store\r\n\
106 Connection: close\r\n\r\n{body}",
107 len = body.len()
108 )
109}
110
111fn parse_request_line(buf: &str) -> Option<(String, String)> {
113 let line = buf.lines().next()?;
114 let parts: Vec<&str> = line.split_whitespace().collect();
115 if parts.len() < 2 {
116 return None;
117 }
118 Some((parts[0].into(), parts[1].into()))
119}
120
121fn handle_connection(mut stream: TcpStream, state: Arc<DashboardState>) {
122 stream.set_read_timeout(Some(Duration::from_secs(2))).ok();
123 stream.set_write_timeout(Some(Duration::from_secs(2))).ok();
124 let mut buf = [0u8; 65_536];
125 let n = match stream.read(&mut buf) {
126 Ok(n) => n,
127 Err(_) => return,
128 };
129 let req = String::from_utf8_lossy(&buf[..n]);
130 let (method, path) = match parse_request_line(&req) {
131 Some(p) => p,
132 None => return,
133 };
134 let body = req.find("\r\n\r\n").map(|i| &req[i + 4..]).unwrap_or("");
136 let (status, ctype, body) = route_with_body(&method, &path, body, &state);
137 let resp = format_response(status, ctype, &body);
138 let _ = stream.write_all(resp.as_bytes());
139}
140
141pub fn run_blocking(addr: SocketAddr, state: Arc<DashboardState>) -> Result<(), ServerError> {
147 let listener = TcpListener::bind(addr).map_err(ServerError::Io)?;
148 println!("zerodds-dashboard: listening on http://{addr}/");
149 for stream in listener.incoming() {
150 let stream = match stream {
151 Ok(s) => s,
152 Err(_) => continue,
153 };
154 let st = Arc::clone(&state);
155 std::thread::spawn(move || handle_connection(stream, st));
156 }
157 Ok(())
158}
159
160#[cfg(test)]
161#[allow(clippy::unwrap_used)] mod tests {
163 use super::*;
164 use crate::state::TopicInfo;
165
166 #[test]
167 fn route_serves_index_html() {
168 let s = DashboardState::new();
169 let (code, ct, body) = route("GET", "/", &s);
170 assert_eq!(code, 200);
171 assert_eq!(ct, "text/html; charset=utf-8");
172 assert!(body.contains("<title>ZeroDDS Dashboard</title>"));
173 }
174
175 #[test]
176 fn route_serves_topics_json() {
177 let s = DashboardState::new();
178 s.set_topics(vec![TopicInfo {
179 name: "/x".into(),
180 type_name: "T".into(),
181 publishers: 0,
182 subscribers: 0,
183 sample_rate_hz: 0.0,
184 }]);
185 let (code, ct, body) = route("GET", "/api/topics", &s);
186 assert_eq!(code, 200);
187 assert_eq!(ct, "application/json");
188 assert!(body.contains(r#""name":"/x""#));
189 }
190
191 #[test]
192 fn route_404_for_unknown() {
193 let s = DashboardState::new();
194 let (code, _, _) = route("GET", "/api/unknown", &s);
195 assert_eq!(code, 404);
196 }
197
198 #[test]
199 fn route_post_toggles_recording() {
200 let s = DashboardState::new();
201 let (code, _, body1) = route("POST", "/api/recording/toggle", &s);
202 assert_eq!(code, 200);
203 assert!(body1.contains(r#""active":true"#));
204 let (_, _, body2) = route("POST", "/api/recording/toggle", &s);
205 assert!(body2.contains(r#""active":false"#));
206 }
207
208 #[test]
209 fn parse_request_line_smoke() {
210 let r = "GET /api/topics HTTP/1.1\r\nHost: x\r\n\r\n";
211 let (m, p) = parse_request_line(r).unwrap();
212 assert_eq!(m, "GET");
213 assert_eq!(p, "/api/topics");
214 }
215
216 #[test]
217 fn format_response_includes_status_and_length() {
218 let r = format_response(200, "application/json", "{}");
219 assert!(r.starts_with("HTTP/1.1 200 OK"));
220 assert!(r.contains("Content-Length: 2"));
221 assert!(r.contains("Cache-Control: no-store"));
222 }
223
224 #[test]
225 fn format_response_404() {
226 let r = format_response(404, "text/plain", "no\n");
227 assert!(r.starts_with("HTTP/1.1 404 Not Found"));
228 }
229
230 #[test]
231 fn route_inject_topics_accepts_json() {
232 let s = DashboardState::new();
233 let body = r#"[{"name":"/x","type_name":"T","publishers":1,"subscribers":2,"sample_rate_hz":50.0}]"#;
234 let (code, _, resp) = route_with_body("POST", "/api/inject/topics", body, &s);
235 assert_eq!(code, 200);
236 assert!(resp.contains(r#""injected":1"#));
237 let (_, _, get_body) = route("GET", "/api/topics", &s);
239 assert!(get_body.contains(r#""name":"/x""#));
240 }
241
242 #[test]
243 fn route_inject_topics_rejects_invalid_json() {
244 let s = DashboardState::new();
245 let (code, _, _) = route_with_body("POST", "/api/inject/topics", "not json", &s);
246 assert_eq!(code, 400);
247 }
248}