qml_rs/dashboard/
server.rs1use axum::{Router, http::StatusCode, response::Html, routing::get};
2use std::net::SocketAddr;
3use std::sync::Arc;
4use tokio::net::TcpListener;
5use tower::ServiceBuilder;
6use tower_http::cors::CorsLayer;
7
8use crate::dashboard::{
9 routes::create_router,
10 service::DashboardService,
11 websocket::{WebSocketManager, websocket_handler},
12};
13use crate::storage::Storage;
14
15#[derive(Debug, Clone)]
16pub struct DashboardConfig {
17 pub host: String,
18 pub port: u16,
19 pub statistics_update_interval: u64,
20}
21
22impl Default for DashboardConfig {
23 fn default() -> Self {
24 Self {
25 host: "127.0.0.1".to_string(),
26 port: 8080,
27 statistics_update_interval: 5, }
29 }
30}
31
32pub struct DashboardServer {
33 config: DashboardConfig,
34 dashboard_service: Arc<DashboardService>,
35 websocket_manager: Arc<WebSocketManager>,
36}
37
38impl DashboardServer {
39 pub fn new(storage: Arc<dyn Storage>, config: DashboardConfig) -> Self {
40 let dashboard_service = Arc::new(DashboardService::new(storage));
41 let websocket_manager = Arc::new(WebSocketManager::new(Arc::clone(&dashboard_service)));
42
43 Self {
44 config,
45 dashboard_service,
46 websocket_manager,
47 }
48 }
49
50 pub async fn start(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
52 let addr: SocketAddr = format!("{}:{}", self.config.host, self.config.port).parse()?;
53
54 let app = self.create_app().await;
56
57 self.websocket_manager
59 .start_periodic_updates(self.config.statistics_update_interval)
60 .await;
61
62 tracing::info!("Starting QML Dashboard server on http://{}", addr);
63 tracing::info!("Dashboard available at: http://{}", addr);
64
65 let listener = TcpListener::bind(addr).await?;
66 axum::serve(listener, app).await?;
67
68 Ok(())
69 }
70
71 async fn create_app(&self) -> Router {
73 let api_router = create_router(Arc::clone(&self.dashboard_service));
75
76 let ws_router = Router::new()
78 .route("/ws", get(websocket_handler))
79 .with_state(Arc::clone(&self.websocket_manager));
80
81 let ui_router = Router::new()
83 .route("/", get(dashboard_ui))
84 .route("/dashboard", get(dashboard_ui))
85 .route("/jobs", get(dashboard_ui))
86 .route("/queues", get(dashboard_ui))
87 .route("/statistics", get(dashboard_ui));
88
89 Router::new()
91 .merge(api_router)
92 .merge(ws_router)
93 .merge(ui_router)
94 .layer(
95 ServiceBuilder::new()
96 .layer(CorsLayer::permissive()) .into_inner(),
98 )
99 }
100
101 pub fn websocket_manager(&self) -> Arc<WebSocketManager> {
103 Arc::clone(&self.websocket_manager)
104 }
105
106 pub fn dashboard_service(&self) -> Arc<DashboardService> {
108 Arc::clone(&self.dashboard_service)
109 }
110}
111
112async fn dashboard_ui() -> Result<Html<&'static str>, StatusCode> {
114 Ok(Html(DASHBOARD_HTML))
115}
116
117const DASHBOARD_HTML: &str = r#"
119<!DOCTYPE html>
120<html lang="en">
121<head>
122 <meta charset="UTF-8">
123 <meta name="viewport" content="width=device-width, initial-scale=1.0">
124 <title>QML Dashboard</title>
125 <style>
126 * {
127 margin: 0;
128 padding: 0;
129 box-sizing: border-box;
130 }
131
132 body {
133 font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
134 background-color: #f5f5f5;
135 color: #333;
136 line-height: 1.6;
137 }
138
139 .container {
140 max-width: 1200px;
141 margin: 0 auto;
142 padding: 20px;
143 }
144
145 header {
146 background: #2c3e50;
147 color: white;
148 padding: 1rem 0;
149 margin-bottom: 2rem;
150 }
151
152 header h1 {
153 text-align: center;
154 font-size: 2rem;
155 }
156
157 .stats-grid {
158 display: grid;
159 grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
160 gap: 20px;
161 margin-bottom: 2rem;
162 }
163
164 .stat-card {
165 background: white;
166 border-radius: 8px;
167 padding: 20px;
168 box-shadow: 0 2px 10px rgba(0,0,0,0.1);
169 text-align: center;
170 }
171
172 .stat-card h3 {
173 color: #2c3e50;
174 margin-bottom: 10px;
175 font-size: 1.2rem;
176 }
177
178 .stat-card .number {
179 font-size: 2rem;
180 font-weight: bold;
181 color: #3498db;
182 }
183
184 .section {
185 background: white;
186 border-radius: 8px;
187 padding: 20px;
188 margin-bottom: 20px;
189 box-shadow: 0 2px 10px rgba(0,0,0,0.1);
190 }
191
192 .section h2 {
193 color: #2c3e50;
194 margin-bottom: 15px;
195 border-bottom: 2px solid #3498db;
196 padding-bottom: 10px;
197 }
198
199 table {
200 width: 100%;
201 border-collapse: collapse;
202 margin-top: 10px;
203 }
204
205 th, td {
206 padding: 12px;
207 text-align: left;
208 border-bottom: 1px solid #ddd;
209 }
210
211 th {
212 background-color: #f8f9fa;
213 font-weight: 600;
214 color: #2c3e50;
215 }
216
217 .status {
218 padding: 4px 8px;
219 border-radius: 4px;
220 font-size: 0.8rem;
221 font-weight: bold;
222 text-transform: uppercase;
223 }
224
225 .status.succeeded { background: #d4edda; color: #155724; }
226 .status.failed { background: #f8d7da; color: #721c24; }
227 .status.processing { background: #d1ecf1; color: #0c5460; }
228 .status.enqueued { background: #fff3cd; color: #856404; }
229 .status.scheduled { background: #e2e3e5; color: #383d41; }
230 .status.awaiting_retry { background: #fce4ec; color: #c2185b; }
231
232 .connection-status {
233 position: fixed;
234 top: 20px;
235 right: 20px;
236 padding: 10px 15px;
237 border-radius: 5px;
238 font-weight: bold;
239 z-index: 1000;
240 }
241
242 .connection-status.connected {
243 background: #d4edda;
244 color: #155724;
245 }
246
247 .connection-status.disconnected {
248 background: #f8d7da;
249 color: #721c24;
250 }
251
252 .btn {
253 padding: 8px 16px;
254 border: none;
255 border-radius: 4px;
256 cursor: pointer;
257 font-size: 0.9rem;
258 margin: 2px;
259 }
260
261 .btn-primary { background: #3498db; color: white; }
262 .btn-success { background: #27ae60; color: white; }
263 .btn-danger { background: #e74c3c; color: white; }
264
265 .btn:hover {
266 opacity: 0.9;
267 }
268
269 .refresh-indicator {
270 display: inline-block;
271 margin-left: 10px;
272 color: #3498db;
273 }
274
275 @keyframes spin {
276 0% { transform: rotate(0deg); }
277 100% { transform: rotate(360deg); }
278 }
279
280 .spinning {
281 animation: spin 1s linear infinite;
282 }
283 </style>
284</head>
285<body>
286 <header>
287 <div class="container">
288 <h1>🔥 QML Dashboard</h1>
289 </div>
290 </header>
291
292 <div class="connection-status" id="connectionStatus">
293 Connecting...
294 </div>
295
296 <div class="container">
297 <div class="stats-grid" id="statsGrid">
298 <!-- Statistics will be populated here -->
299 </div>
300
301 <div class="section">
302 <h2>Recent Jobs <span class="refresh-indicator" id="refreshIndicator">🔄</span></h2>
303 <table id="jobsTable">
304 <thead>
305 <tr>
306 <th>ID</th>
307 <th>Method</th>
308 <th>Queue</th>
309 <th>Status</th>
310 <th>Created</th>
311 <th>Attempts</th>
312 <th>Actions</th>
313 </tr>
314 </thead>
315 <tbody>
316 <!-- Jobs will be populated here -->
317 </tbody>
318 </table>
319 </div>
320
321 <div class="section">
322 <h2>Queue Statistics</h2>
323 <table id="queuesTable">
324 <thead>
325 <tr>
326 <th>Queue Name</th>
327 <th>Enqueued</th>
328 <th>Processing</th>
329 <th>Scheduled</th>
330 </tr>
331 </thead>
332 <tbody>
333 <!-- Queues will be populated here -->
334 </tbody>
335 </table>
336 </div>
337 </div>
338
339 <script>
340 let ws = null;
341 let reconnectInterval = null;
342
343 function connectWebSocket() {
344 const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
345 const wsUrl = `${protocol}//${window.location.host}/ws`;
346
347 ws = new WebSocket(wsUrl);
348
349 ws.onopen = function() {
350 console.log('WebSocket connected');
351 updateConnectionStatus(true);
352 if (reconnectInterval) {
353 clearInterval(reconnectInterval);
354 reconnectInterval = null;
355 }
356 };
357
358 ws.onmessage = function(event) {
359 const message = JSON.parse(event.data);
360 console.log('Received message:', message);
361
362 switch (message.type) {
363 case 'statistics_update':
364 updateStatistics(message.data);
365 updateRefreshIndicator();
366 break;
367 case 'job_update':
368 console.log('Job update:', message);
369 break;
370 case 'connection_info':
371 console.log('Connection info:', message);
372 break;
373 }
374 };
375
376 ws.onclose = function() {
377 console.log('WebSocket disconnected');
378 updateConnectionStatus(false);
379 if (!reconnectInterval) {
380 reconnectInterval = setInterval(connectWebSocket, 5000);
381 }
382 };
383
384 ws.onerror = function(error) {
385 console.error('WebSocket error:', error);
386 updateConnectionStatus(false);
387 };
388 }
389
390 function updateConnectionStatus(connected) {
391 const status = document.getElementById('connectionStatus');
392 if (connected) {
393 status.textContent = 'Connected';
394 status.className = 'connection-status connected';
395 } else {
396 status.textContent = 'Disconnected';
397 status.className = 'connection-status disconnected';
398 }
399 }
400
401 function updateStatistics(data) {
402 const statsGrid = document.getElementById('statsGrid');
403 statsGrid.innerHTML = `
404 <div class="stat-card">
405 <h3>Total Jobs</h3>
406 <div class="number">${data.jobs.total_jobs}</div>
407 </div>
408 <div class="stat-card">
409 <h3>Succeeded</h3>
410 <div class="number" style="color: #27ae60;">${data.jobs.succeeded}</div>
411 </div>
412 <div class="stat-card">
413 <h3>Failed</h3>
414 <div class="number" style="color: #e74c3c;">${data.jobs.failed}</div>
415 </div>
416 <div class="stat-card">
417 <h3>Processing</h3>
418 <div class="number" style="color: #3498db;">${data.jobs.processing}</div>
419 </div>
420 <div class="stat-card">
421 <h3>Enqueued</h3>
422 <div class="number" style="color: #f39c12;">${data.jobs.enqueued}</div>
423 </div>
424 <div class="stat-card">
425 <h3>Scheduled</h3>
426 <div class="number" style="color: #9b59b6;">${data.jobs.scheduled}</div>
427 </div>
428 `;
429
430 updateJobsTable(data.recent_jobs);
431 updateQueuesTable(data.queues);
432 }
433
434 function updateJobsTable(jobs) {
435 const tbody = document.querySelector('#jobsTable tbody');
436 tbody.innerHTML = jobs.map(job => `
437 <tr>
438 <td>${job.id.substring(0, 8)}...</td>
439 <td>${job.method_name}</td>
440 <td>${job.queue}</td>
441 <td><span class="status ${job.state.toLowerCase()}">${job.state}</span></td>
442 <td>${new Date(job.created_at).toLocaleString()}</td>
443 <td>${job.attempts}/${job.max_attempts}</td>
444 <td>
445 ${job.state === 'Failed' ? `<button class="btn btn-success" onclick="retryJob('${job.id}')">Retry</button>` : ''}
446 <button class="btn btn-danger" onclick="deleteJob('${job.id}')">Delete</button>
447 </td>
448 </tr>
449 `).join('');
450 }
451
452 function updateQueuesTable(queues) {
453 const tbody = document.querySelector('#queuesTable tbody');
454 tbody.innerHTML = queues.map(queue => `
455 <tr>
456 <td>${queue.queue_name}</td>
457 <td>${queue.enqueued_count}</td>
458 <td>${queue.processing_count}</td>
459 <td>${queue.scheduled_count}</td>
460 </tr>
461 `).join('');
462 }
463
464 function updateRefreshIndicator() {
465 const indicator = document.getElementById('refreshIndicator');
466 indicator.classList.add('spinning');
467 setTimeout(() => {
468 indicator.classList.remove('spinning');
469 }, 1000);
470 }
471
472 async function retryJob(jobId) {
473 try {
474 const response = await fetch(`/api/jobs/${jobId}/retry`, {
475 method: 'POST',
476 });
477 const result = await response.json();
478 if (result.success) {
479 console.log('Job retried successfully');
480 } else {
481 console.error('Failed to retry job:', result.error);
482 }
483 } catch (error) {
484 console.error('Error retrying job:', error);
485 }
486 }
487
488 async function deleteJob(jobId) {
489 if (confirm('Are you sure you want to delete this job?')) {
490 try {
491 const response = await fetch(`/api/jobs/${jobId}`, {
492 method: 'DELETE',
493 });
494 const result = await response.json();
495 if (result.success) {
496 console.log('Job deleted successfully');
497 } else {
498 console.error('Failed to delete job:', result.error);
499 }
500 } catch (error) {
501 console.error('Error deleting job:', error);
502 }
503 }
504 }
505
506 // Initialize
507 connectWebSocket();
508 </script>
509</body>
510</html>
511"#;