qml_rs/dashboard/
server.rs

1use 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, // Update every 5 seconds
28        }
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    /// Start the dashboard server
51    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        // Create the main router
55        let app = self.create_app().await;
56
57        // Start periodic statistics updates
58        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    /// Create the main application router
72    async fn create_app(&self) -> Router {
73        // Create API router
74        let api_router = create_router(Arc::clone(&self.dashboard_service));
75
76        // Create WebSocket route
77        let ws_router = Router::new()
78            .route("/ws", get(websocket_handler))
79            .with_state(Arc::clone(&self.websocket_manager));
80
81        // Main dashboard UI route
82        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        // Combine all routers
90        Router::new()
91            .merge(api_router)
92            .merge(ws_router)
93            .merge(ui_router)
94            .layer(
95                ServiceBuilder::new()
96                    .layer(CorsLayer::permissive()) // Allow all origins for development
97                    .into_inner(),
98            )
99    }
100
101    /// Get the WebSocket manager for external use
102    pub fn websocket_manager(&self) -> Arc<WebSocketManager> {
103        Arc::clone(&self.websocket_manager)
104    }
105
106    /// Get the dashboard service for external use
107    pub fn dashboard_service(&self) -> Arc<DashboardService> {
108        Arc::clone(&self.dashboard_service)
109    }
110}
111
112/// Dashboard UI handler - serves the main HTML page
113async fn dashboard_ui() -> Result<Html<&'static str>, StatusCode> {
114    Ok(Html(DASHBOARD_HTML))
115}
116
117/// Embedded HTML for the dashboard UI
118const 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"#;