Skip to main content

zerodds_dashboard/
server.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 ZeroDDS Contributors
3
4//! Mini-HTTP-Server fuer das Dashboard. Pure-Rust, keine externen
5//! Crates. Reicht fuer Localhost-Tools — kein production-grade
6//! HTTP/1.1, kein chunked, kein keep-alive.
7
8use 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/// Server-Fehler.
17#[derive(Debug)]
18pub enum ServerError {
19    /// Bind/Accept/IO-Fehler.
20    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
33/// Wie [`route`] aber mit Body fuer POST-Inject.
34pub 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
61/// Routet eine HTTP-Request-Line. Returnt `(status, content_type, body)`.
62pub 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            // Phase-A: toggle nur in-state. Phase-B: Hook zum
74            // zerodds-recorder, der live in eine zddsrec-Datei schreibt.
75            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
92/// Formatiert eine HTTP-Response.
93fn 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
111/// Parsed die Request-Line aus dem Request-Header.
112fn 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    // Body extrahieren (alles nach \r\n\r\n).
135    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
141/// Blockierender Server-Loop. Akzeptiert Connections und beantwortet
142/// jede in einem eigenen Thread (kein Async-Runtime, einfach).
143///
144/// # Errors
145/// IO-Fehler beim TCP-Bind/Accept.
146pub 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)] // tests duerfen unwrap nutzen.
162mod 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        // Datei laesst sich nun via GET wieder lesen.
238        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}