feat(dashboard): extract inline script to standalone dashboard.ts/js, add UI build/check recipes, and add route unit tests

This commit is contained in:
Riz Ashraf committed 2026-10-05 12:48:36 +01:00
1 parent 4b307b55b9
commit 19f9c7ace7
24 files changed
+2343 -1819

No files matched your search

+38
View File
@@ -161,6 +161,12 @@ pub fn create_router(app_state: Arc<AppState>) -> Router {
"/",
get(|| async move { axum::response::Html(include_str!("../dashboard.html")) }),
)
.route(
"/dashboard.js",
get(|| async move {
([(axum::http::header::CONTENT_TYPE, "application/javascript")], include_str!("../dashboard.js"))
}),
)
.route(
"/api/graph",
get({
@@ -675,6 +681,38 @@ mod tests {
let shut_resp = app_shut.oneshot(shut_req).await.unwrap();
assert_eq!(shut_resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn test_dashboard_endpoint_and_html_integrity() {
let (_app, app_state, _dir) = setup_app().await;
// Test GET / (dashboard.html)
let app_html = create_router(app_state.clone());
let req_html = Request::builder().uri("/").body(Body::empty()).unwrap();
let resp_html = app_html.oneshot(req_html).await.unwrap();
assert_eq!(resp_html.status(), StatusCode::OK);
let content_type = resp_html.headers().get(axum::http::header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(content_type.contains("text/html"));
let body_bytes = axum::body::to_bytes(resp_html.into_body(), usize::MAX).await.unwrap();
let html_str = String::from_utf8(body_bytes.to_vec()).unwrap();
assert!(html_str.contains("<script src=\"/dashboard.js\"></script>"));
// Test GET /dashboard.js
let app_js = create_router(app_state.clone());
let req_js = Request::builder().uri("/dashboard.js").body(Body::empty()).unwrap();
let resp_js = app_js.oneshot(req_js).await.unwrap();
assert_eq!(resp_js.status(), StatusCode::OK);
let content_type_js = resp_js.headers().get(axum::http::header::CONTENT_TYPE).unwrap().to_str().unwrap();
assert!(content_type_js.contains("application/javascript"));
let js_bytes = axum::body::to_bytes(resp_js.into_body(), usize::MAX).await.unwrap();
let js_str = String::from_utf8(js_bytes.to_vec()).unwrap();
assert!(js_str.contains("escapeHtml"));
assert!(js_str.contains("setupWS"));
assert!(js_str.contains("refreshActiveTab"));
assert!(js_str.contains("parseActivityPayload"));
}
}
+113
View File
@@ -54,6 +54,40 @@ pub async fn handle_socket(socket: WebSocket, state: Arc<AppState>, _client_type
.unwrap_or_else(|e| e.into_inner())
.insert(session_id.clone(), tx.clone());
// Reconnection Catch-Up: Replay recent TASK_EVENT notifications so client receives missed Futures
let recent_task_notifications: Vec<String> = state
.handler
.state
.telemetry
.recent_activities
.read_with(|activities| {
activities
.iter()
.filter_map(|act_val| {
if act_val["category"] == "TASK_EVENT" {
if let Some(details_str) = act_val["details"].as_str() {
if let Ok(event_val) = serde_json::from_str::<serde_json::Value>(details_str) {
return Some(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/task/completed",
"params": event_val
})
.to_string(),
);
}
}
}
None
})
.take(5)
.collect()
});
for notif in recent_task_notifications.into_iter().rev() {
let _ = tx.try_send(notif);
}
let (mut sender, mut receiver) = socket.split();
let send_task = tokio::spawn(async move {
@@ -185,4 +219,83 @@ mod tests {
assert!(app_state.clients.read().unwrap().is_empty());
}
#[tokio::test]
async fn test_task_event_broadcast_and_reconnection_replay() {
let dir = tempdir().unwrap();
let mem_state = Arc::new(MemoryState::new(dir.path().to_str().unwrap()));
let task_event = crate::models::TaskEvent {
task_id: "task-999".to_string(),
status: "completed".to_string(),
action: Some("update".to_string()),
result: Some(serde_json::json!({"status": "completed"})),
error: None,
timestamp: 1728129000,
session_id: None,
};
// Broadcast task event
mem_state.broadcast_task_event(task_event.clone());
// Verify recent activities recorded the event
let recorded = mem_state.telemetry.recent_activities.read_with(|act| act.clone());
assert!(!recorded.is_empty());
assert_eq!(recorded[0]["category"], "TASK_EVENT");
// Verify reconnection catch-up replay fetches the notification
let (shutdown_tx, _) = tokio::sync::oneshot::channel();
let app_state = Arc::new(AppState {
handler: Arc::new(MemoryHandler::new(mem_state)),
clients: RwLock::new(HashMap::new()),
next_id: AtomicUsize::new(1),
shutdown_tx: std::sync::Mutex::new(Some(shutdown_tx)),
});
let (tx, mut rx) = mpsc::channel::<String>(10);
app_state
.clients
.write()
.unwrap()
.insert("session-1".to_string(), tx.clone());
let recent_notifications: Vec<String> = app_state
.handler
.state
.telemetry
.recent_activities
.read_with(|activities| {
activities
.iter()
.filter_map(|act_val| {
if act_val["category"] == "TASK_EVENT" {
if let Some(details_str) = act_val["details"].as_str() {
if let Ok(event_val) = serde_json::from_str::<serde_json::Value>(details_str) {
return Some(
serde_json::json!({
"jsonrpc": "2.0",
"method": "notifications/task/completed",
"params": event_val
})
.to_string(),
);
}
}
}
None
})
.take(5)
.collect()
});
for notif in recent_notifications {
let _ = tx.try_send(notif);
}
let replayed_msg = rx.recv().await.expect("Expected replayed task event notification");
let parsed: serde_json::Value = serde_json::from_str(&replayed_msg).unwrap();
assert_eq!(parsed["method"], "notifications/task/completed");
assert_eq!(parsed["params"]["task_id"], "task-999");
assert_eq!(parsed["params"]["status"], "completed");
}
}