roboticus_api/
dashboard.rs1use axum::extract::State;
2use axum::response::{Html, IntoResponse, Response};
3
4use crate::api::AppState;
5
6pub async fn dashboard_handler(State(state): State<AppState>) -> Response {
7 let config = state.config.read().await;
8 let key = config.server.api_key.as_deref();
9
10 let nonce = uuid::Uuid::new_v4().to_string();
12 let html = build_dashboard_html(key, &nonce);
13
14 let csp = format!(
15 "default-src 'self'; script-src 'self' 'nonce-{nonce}'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'"
16 );
17
18 let mut response = Html(html).into_response();
19 if let Ok(csp_value) = axum::http::HeaderValue::from_str(&csp) {
20 response.headers_mut().insert(
21 axum::http::header::HeaderName::from_static("content-security-policy"),
22 csp_value,
23 );
24 } else {
25 tracing::error!("CSP header contained non-ASCII; applying fallback CSP");
27 response.headers_mut().insert(
28 axum::http::header::HeaderName::from_static("content-security-policy"),
29 axum::http::HeaderValue::from_static(
30 "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'"
31 ),
32 );
33 }
34 response
35}
36
37pub fn build_dashboard_html(_api_key: Option<&str>, nonce: &str) -> String {
38 let css = [
44 include_str!("dashboard/css/variables.css"),
45 include_str!("dashboard/css/layout.css"),
46 include_str!("dashboard/css/components.css"),
47 include_str!("dashboard/css/animations.css"),
48 ]
49 .join("\n");
50
51 let js = [
52 include_str!("dashboard/js/utils.js"),
53 include_str!("dashboard/js/api.js"),
54 include_str!("dashboard/js/hints.js"),
55 include_str!("dashboard/js/charts.js"),
56 include_str!("dashboard/js/app-core.js"),
57 include_str!("dashboard/js/pages/overview.js"),
58 include_str!("dashboard/js/pages/sessions.js"),
59 include_str!("dashboard/js/pages/memory.js"),
60 include_str!("dashboard/js/pages/skills.js"),
61 include_str!("dashboard/js/pages/scheduler.js"),
62 include_str!("dashboard/js/pages/integrations.js"),
63 include_str!("dashboard/js/pages/metrics.js"),
64 include_str!("dashboard/js/pages/efficiency.js"),
65 include_str!("dashboard/js/pages/agents.js"),
66 include_str!("dashboard/js/pages/context.js"),
67 include_str!("dashboard/js/pages/settings.js"),
68 include_str!("dashboard/js/pages/wallet.js"),
69 include_str!("dashboard/js/pages/workspace.js"),
70 include_str!("dashboard/js/themes.js"),
71 include_str!("dashboard/js/help-modal.js"),
72 include_str!("dashboard/js/events.js"),
73 include_str!("dashboard/js/websocket.js"),
74 ]
75 .join("\n");
76
77 let template = include_str!("dashboard/template.html");
78 let assembled = template
79 .replace("/* {CSS} */", &css)
80 .replace("/* {JS} */", &js);
81
82 let with_vars = assembled.replace("var BASE = '';", "var BASE = ''; var API_KEY = null;");
83 with_vars.replace("<script>", &format!("<script nonce=\"{nonce}\">"))
85}
86
87#[cfg(test)]
88mod tests {
89 use super::*;
90
91 const TEST_NONCE: &str = "test-nonce-value";
92
93 #[test]
94 fn dashboard_html_contains_title() {
95 let html = build_dashboard_html(None, TEST_NONCE);
96 assert!(html.contains("<title>Roboticus Dashboard</title>"));
97 assert!(html.contains("/api/health"));
98 }
99
100 #[test]
101 fn build_dashboard_html_contains_all_sections() {
102 let html = build_dashboard_html(None, TEST_NONCE);
103 assert!(html.contains("Roboticus"));
104 assert!(html.contains("Overview"));
105 assert!(html.contains("/api/sessions"));
106 assert!(html.contains("/api/memory/episodic"));
107 assert!(html.contains("/api/cron/jobs"));
108 assert!(html.contains("/api/stats/costs"));
109 assert!(html.contains("/api/skills"));
110 assert!(html.contains("/api/wallet/balance"));
111 assert!(html.contains("/api/breaker/status"));
112 }
113
114 #[test]
115 fn dashboard_html_contains_catalog_controls() {
116 let html = build_dashboard_html(None, TEST_NONCE);
117 assert!(html.contains("/api/skills/catalog"));
118 assert!(html.contains("/api/skills/catalog/install"));
119 assert!(html.contains("/api/skills/catalog/activate"));
120 assert!(html.contains("btn-catalog-install"));
121 assert!(html.contains("btn-catalog-install-activate"));
122 assert!(html.contains("cat-skill-check"));
123 }
124
125 #[test]
126 fn dashboard_html_without_key_has_api_health() {
127 let html = build_dashboard_html(None, TEST_NONCE);
128 assert!(html.contains("<title>Roboticus Dashboard</title>"));
129 assert!(html.contains("/api/health"));
130 }
131
132 #[test]
133 fn dashboard_never_injects_api_key() {
134 let html = build_dashboard_html(Some("test-dashboard-key"), TEST_NONCE);
135 assert!(
136 html.contains("API_KEY = null"),
137 "API key must never be embedded"
138 );
139 }
140
141 #[test]
142 fn dashboard_null_api_key_always() {
143 let html = build_dashboard_html(None, TEST_NONCE);
144 assert!(html.contains("API_KEY = null"));
145 }
146
147 #[test]
148 fn dashboard_html_contains_single_html_close_tag() {
149 let html = build_dashboard_html(None, TEST_NONCE);
150 assert_eq!(html.matches("</html>").count(), 1);
151 }
152
153 #[test]
154 fn dashboard_html_injects_nonce_into_script_tags() {
155 let html = build_dashboard_html(None, TEST_NONCE);
156 assert!(
157 html.contains(&format!("<script nonce=\"{TEST_NONCE}\">")),
158 "script tags must include nonce attribute"
159 );
160 assert!(
161 !html.contains("<script>"),
162 "no bare <script> tags should remain"
163 );
164 }
165
166 #[test]
167 fn dashboard_nonce_is_unique_per_call() {
168 let nonce_a = uuid::Uuid::new_v4().to_string();
169 let nonce_b = uuid::Uuid::new_v4().to_string();
170 assert_ne!(nonce_a, nonce_b);
171
172 let html_a = build_dashboard_html(None, &nonce_a);
173 let html_b = build_dashboard_html(None, &nonce_b);
174 assert!(html_a.contains(&nonce_a));
175 assert!(html_b.contains(&nonce_b));
176 assert!(!html_a.contains(&nonce_b));
177 }
178}