1use crate::error::Error;
2use axum::extract::Path;
3use axum::extract::State;
4use axum::http::header;
5use axum::http::StatusCode;
6use axum::response::Html;
7use axum::response::Response;
8use axum::routing::get;
9use axum::Router;
10use notify::Config;
11use notify::RecommendedWatcher;
12use notify::RecursiveMode;
13use notify::Watcher;
14use serde::Serialize;
15use std::collections::HashMap;
16use std::path::PathBuf;
17use std::sync::Arc;
18use std::sync::Mutex;
19use std::time::SystemTime;
20use tokio::fs;
21use tower_http::cors::CorsLayer;
22use tower_http::services::ServeDir;
23
24#[derive(Debug, Clone, Serialize)]
25pub struct FileInfo {
26 pub name: String,
27 pub path: String,
28 pub modified: SystemTime,
29 pub size: u64,
30 pub file_type: FileType,
31}
32
33#[derive(Debug, Clone, Serialize)]
34pub enum FileType {
35 Dashboard,
36 Log,
37}
38
39#[derive(Clone)]
40struct AppState {
41 files: Arc<Mutex<HashMap<String, FileInfo>>>,
42 base_path: PathBuf,
43}
44
45impl AppState {
46 fn new(base_path: PathBuf) -> Self {
47 Self {
48 files: Arc::new(Mutex::new(HashMap::new())),
49 base_path,
50 }
51 }
52
53 fn update_files(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
54 let mut files = self.files.lock().unwrap();
55 files.clear();
56
57 if !self.base_path.exists() {
58 return Ok(());
59 }
60
61 for entry in std::fs::read_dir(&self.base_path)? {
62 let entry = entry?;
63 let path = entry.path();
64
65 if path.is_file() {
66 if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
67 if let Some(file_type) = determine_file_type(&path) {
68 let metadata = entry.metadata()?;
69
70 let info = FileInfo {
71 name: name.to_string(),
72 path: path.to_string_lossy().to_string(),
73 modified: metadata.modified()?,
74 size: metadata.len(),
75 file_type,
76 };
77 files.insert(name.to_string(), info);
78 }
79 }
80 }
81 }
82
83 Ok(())
84 }
85
86 fn get_files(&self) -> Vec<FileInfo> {
87 let files = self.files.lock().unwrap();
88 let mut sorted: Vec<_> = files.values().cloned().collect();
89 sorted.sort_by(|a, b| b.modified.cmp(&a.modified));
90 sorted
91 }
92}
93
94fn determine_file_type(path: &std::path::Path) -> Option<FileType> {
95 if let Some(extension) = path.extension().and_then(|e| e.to_str()) {
96 match extension.to_lowercase().as_str() {
97 "html" | "htm" => Some(FileType::Dashboard),
98 "log" => Some(FileType::Log),
99 _ => None,
100 }
101 } else {
102 None
103 }
104}
105
106async fn file_list(State(state): State<AppState>) -> Result<Html<String>, StatusCode> {
107 let _ = state.update_files();
108 let files = state.get_files();
109
110 let html = generate_file_list_html(files);
111 Ok(Html(html))
112}
113
114async fn serve_file(
115 State(state): State<AppState>,
116 Path(filename): Path<String>,
117) -> Result<Response, StatusCode> {
118 let file_path = state.base_path.join(&filename);
119
120 if !file_path.exists() || !file_path.is_file() {
121 return Err(StatusCode::NOT_FOUND);
122 }
123
124 if let Some(file_type) = determine_file_type(&file_path) {
125 match fs::read_to_string(&file_path).await {
126 Ok(content) => {
127 let (content_type, formatted_content) = match file_type {
128 FileType::Dashboard => ("text/html; charset=utf-8", content),
129 FileType::Log => (
130 "text/html; charset=utf-8",
131 format_log_as_html(&content, &filename),
132 ),
133 };
134
135 let response = Response::builder()
136 .header(header::CONTENT_TYPE, content_type)
137 .body(formatted_content.into())
138 .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
139 Ok(response)
140 }
141 Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
142 }
143 } else {
144 Err(StatusCode::FORBIDDEN)
145 }
146}
147
148fn generate_file_list_html(files: Vec<FileInfo>) -> String {
149 let template = include_str!("server/dashboard_list_template.html");
151
152 let mut dashboards: Vec<&FileInfo> = files
154 .iter()
155 .filter(|f| matches!(f.file_type, FileType::Dashboard))
156 .collect();
157 let mut logs: Vec<&FileInfo> = files
158 .iter()
159 .filter(|f| matches!(f.file_type, FileType::Log))
160 .collect();
161
162 dashboards.sort_by(|a, b| b.modified.cmp(&a.modified));
164 logs.sort_by(|a, b| b.modified.cmp(&a.modified));
165
166 let sections = if files.is_empty() {
167 r#"
168 <div class="no-dashboards">
169 <div class="no-dashboards-icon">📊</div>
170 <h3>No Files Available</h3>
171 <p>No fuzzing files found in the monitored directory.</p>
172 <p style="font-size: 0.9em; margin-top: 8px;">Start fuzzing to see results here.</p>
173 </div>
174 "#
175 .to_string()
176 } else {
177 let mut sections = String::new();
178
179 if !dashboards.is_empty() {
181 sections.push_str(&format!(
182 r#"
183 <div class="section-header">
184 <h2>📊 Fuzzing Dashboards <span class="count">({} files)</span></h2>
185 </div>
186 <div class="dashboards-grid">
187 "#,
188 dashboards.len()
189 ));
190
191 for dashboard in dashboards {
192 let time_str = format_time(dashboard.modified);
193 sections.push_str(&format!(
194 r#"
195 <div class="file-item dashboard-type">
196 <div class="file-header">
197 <h3 class="file-title">📊 {}</h3>
198 <div class="file-meta">
199 <span class="file-time">{}</span>
200 </div>
201 </div>
202 <div class="file-actions">
203 <a href="/file/{}" class="btn btn-primary">
204 View Dashboard
205 </a>
206 </div>
207 </div>
208 "#,
209 dashboard.name, time_str, dashboard.name
210 ));
211 }
212
213 sections.push_str("</div>");
214 }
215
216 if !logs.is_empty() {
218 sections.push_str(&format!(
219 r#"
220 <div class="section-header">
221 <h2>📋 Debug Logs <span class="count">({} files)</span></h2>
222 </div>
223 <div class="dashboards-grid">
224 "#,
225 logs.len()
226 ));
227
228 for log in logs {
229 let time_str = format_time(log.modified);
230 sections.push_str(&format!(
231 r#"
232 <div class="file-item log-type">
233 <div class="file-header">
234 <h3 class="file-title">📋 {}</h3>
235 <div class="file-meta">
236 <span class="file-time">{}</span>
237 </div>
238 </div>
239 <div class="file-actions">
240 <a href="/file/{}" class="btn btn-secondary">
241 View Log
242 </a>
243 </div>
244 </div>
245 "#,
246 log.name, time_str, log.name
247 ));
248 }
249
250 sections.push_str("</div>");
251 }
252
253 sections
254 };
255
256 template.replace("{{DASHBOARD_ITEMS}}", §ions)
258}
259
260fn format_time(time: SystemTime) -> String {
261 use std::time::UNIX_EPOCH;
262
263 match time.duration_since(UNIX_EPOCH) {
264 Ok(duration) => {
265 let secs = duration.as_secs();
266 let now_secs = SystemTime::now()
267 .duration_since(UNIX_EPOCH)
268 .unwrap_or_default()
269 .as_secs();
270
271 let diff = now_secs.saturating_sub(secs);
272
273 if diff < 60 {
274 "Just now".to_string()
275 } else if diff < 3600 {
276 format!("{} min ago", diff / 60)
277 } else if diff < 86400 {
278 format!("{} hr ago", diff / 3600)
279 } else {
280 format!("{} days ago", diff / 86400)
281 }
282 }
283 Err(_) => "Unknown".to_string(),
284 }
285}
286
287pub struct DashboardServer {
288 directory: PathBuf,
289 host: String,
290 port: u16,
291}
292
293impl DashboardServer {
294 pub fn new(directory: impl Into<PathBuf>, host: String, port: u16) -> Self {
295 Self {
296 directory: directory.into(),
297 host,
298 port,
299 }
300 }
301
302 pub async fn start(&self) -> Result<(), Error> {
303 let base_path = self.directory.clone();
304
305 if !base_path.exists() {
307 tokio::fs::create_dir_all(&base_path).await?;
308 }
309
310 println!("🚀 Starting Trident Dashboard Server");
311 println!("📁 Serving dashboards from: {}", base_path.display());
312 println!("🌐 Server running at: http://{}:{}", self.host, self.port);
313 println!("📊 Dashboard list: http://{}:{}/", self.host, self.port);
314 println!("🔄 Web page auto-refreshes every 3 seconds");
315 println!();
316
317 let state = AppState::new(base_path.clone());
318
319 let _ = state.update_files();
321
322 let watch_state = state.clone();
324 let watch_path = base_path.clone();
325 tokio::spawn(async move {
326 let (tx, mut rx) = tokio::sync::mpsc::channel(100);
327
328 let mut watcher = RecommendedWatcher::new(
329 move |res| {
330 let _ = tx.blocking_send(res);
331 },
332 Config::default(),
333 )
334 .unwrap();
335
336 let _ = watcher.watch(&watch_path, RecursiveMode::NonRecursive);
337
338 while let Some(_event) = rx.recv().await {
339 let _ = watch_state.update_files();
340 }
341 });
342
343 let app = Router::new()
345 .route("/", get(file_list))
346 .route("/file/:filename", get(serve_file))
347 .route("/dashboard/:filename", get(serve_file)) .nest_service("/static", ServeDir::new(&base_path))
349 .layer(CorsLayer::permissive())
350 .with_state(state);
351
352 let addr = format!("{}:{}", self.host, self.port);
354 let listener = tokio::net::TcpListener::bind(&addr).await?;
355
356 println!("✅ Server started successfully!");
357 println!("Press Ctrl+C to stop the server");
358 println!();
359
360 axum::serve(listener, app).await?;
361
362 Ok(())
363 }
364}
365
366fn format_log_as_html(content: &str, filename: &str) -> String {
367 let highlighted_content = content
368 .lines()
369 .map(|line| {
370 let escaped = html_escape(line);
371 if line.contains("ERROR") {
372 format!("<div class=\"log-error\">{}</div>", escaped)
373 } else if line.contains("DEBUG") {
374 format!("<div class=\"log-debug\">{}</div>", escaped)
375 } else if line.contains("Program") && line.contains("invoke") {
376 format!("<div class=\"log-invoke\">{}</div>", escaped)
377 } else if line.contains("Program") && line.contains("success") {
378 format!("<div class=\"log-success\">{}</div>", escaped)
379 } else if line.contains("Program") && line.contains("failed") {
380 format!("<div class=\"log-failed\">{}</div>", escaped)
381 } else if line.contains("PANICKED") {
382 format!("<div class=\"log-panic\">{}</div>", escaped)
383 } else {
384 format!("<div class=\"log-line\">{}</div>", escaped)
385 }
386 })
387 .collect::<Vec<_>>()
388 .join("");
389
390 format!(
391 r#"<!DOCTYPE html>
392<html lang="en">
393<head>
394 <meta charset="UTF-8">
395 <meta name="viewport" content="width=device-width, initial-scale=1.0">
396 <title>{} - Trident Debug Log</title>
397 <style>
398 body {{
399 font-family: 'Courier New', monospace;
400 margin: 0;
401 padding: 20px;
402 background: #0f172a;
403 color: #e2e8f0;
404 font-size: 14px;
405 line-height: 1.4;
406 }}
407 .header {{
408 background: #1e293b;
409 padding: 20px;
410 border-radius: 8px;
411 margin-bottom: 20px;
412 border-left: 4px solid #3b82f6;
413 }}
414 .content {{
415 background: #1e293b;
416 padding: 20px;
417 border-radius: 8px;
418 max-height: 80vh;
419 overflow-y: auto;
420 border: 1px solid #334155;
421 }}
422 .log-line {{ color: #cbd5e1; }}
423 .log-error {{ color: #f87171; font-weight: bold; }}
424 .log-debug {{ color: #94a3b8; }}
425 .log-invoke {{ color: #22c55e; }}
426 .log-success {{ color: #22c55e; font-weight: bold; }}
427 .log-failed {{ color: #f87171; font-weight: bold; }}
428 .log-panic {{ color: #fbbf24; font-weight: bold; background: rgba(251, 191, 36, 0.1); padding: 2px 4px; border-radius: 4px; }}
429 .nav {{ margin-bottom: 20px; }}
430 .nav a {{ color: #38bdf8; text-decoration: none; }}
431 .nav a:hover {{ text-decoration: underline; }}
432 </style>
433</head>
434<body>
435 <div class="nav">
436 <a href="/">← Back to File List</a>
437 </div>
438 <div class="header">
439 <h1>📋 Debug Log: {}</h1>
440 <p>Trident SVM execution log with syntax highlighting</p>
441 </div>
442 <div class="content">
443 {}
444 </div>
445</body>
446</html>"#,
447 filename, filename, highlighted_content
448 )
449}
450
451fn html_escape(text: &str) -> String {
452 text.replace('&', "&")
453 .replace('<', "<")
454 .replace('>', ">")
455 .replace('"', """)
456 .replace('\'', "'")
457}