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:
1 parent
4b307b55b9
commit
19f9c7ace7
24 files changed
+2343
-1819
No files matched your search
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user