1use axum::{
5 extract::{
6 ws::{Message, WebSocket},
7 Path, State, WebSocketUpgrade,
8 },
9 http::StatusCode,
10 response::{Html, Json, Response},
11};
12use chrono::{DateTime, Utc};
13use dashmap::DashMap;
14use futures_util::{SinkExt, StreamExt};
15use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
16use serde::{Deserialize, Serialize};
17use serde_json::Value;
18use std::{fs, path::PathBuf, sync::Arc, time::SystemTime};
19use tokio::sync::broadcast;
20use walkdir::WalkDir;
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct LogEntry {
24 #[serde(rename = "type")]
25 pub entry_type: Option<String>,
26 pub summary: Option<String>,
27 #[serde(rename = "parentUuid")]
28 pub parent_uuid: Option<String>,
29 #[serde(rename = "isSidechain")]
30 pub is_sidechain: Option<bool>,
31 #[serde(rename = "userType")]
32 pub user_type: Option<String>,
33 pub cwd: Option<String>,
34 #[serde(rename = "sessionId")]
35 pub session_id: Option<String>,
36 pub version: Option<String>,
37 pub message: Option<Value>,
38 pub uuid: Option<String>,
39 pub timestamp: Option<DateTime<Utc>>,
40 #[serde(rename = "requestId")]
41 pub request_id: Option<String>,
42 #[serde(rename = "leafUuid")]
43 pub leaf_uuid: Option<String>,
44 #[serde(rename = "toolUseResult")]
45 pub tool_use_result: Option<Value>,
46}
47
48#[derive(Debug, Clone, Serialize)]
49pub struct ProjectSummary {
50 pub name: String,
51 pub path: String,
52 pub session_count: usize,
53 pub latest_activity: Option<DateTime<Utc>>,
54}
55
56#[derive(Debug, Clone, Serialize)]
57pub struct SessionSummary {
58 pub id: String,
59 pub summary: String,
60 pub timestamp: DateTime<Utc>,
61 pub message_count: usize,
62 pub project_name: String,
63}
64
65#[derive(Debug, Clone, Serialize)]
66pub struct WatchEvent {
67 #[serde(rename = "type")]
68 pub event_type: String,
69 pub project: String,
70 pub session: Option<String>,
71 pub entry: Option<LogEntry>,
72 pub timestamp: DateTime<Utc>,
73}
74
75#[derive(Debug, Clone)]
76#[allow(dead_code)]
77pub struct SessionState {
78 pub project_name: String,
79 pub session_file: PathBuf,
80 pub last_position: u64,
81 pub last_modified: SystemTime,
82}
83
84#[derive(Debug)]
85#[allow(dead_code)]
86pub struct WatchManager {
87 _watcher: RecommendedWatcher,
88 active_sessions: Arc<DashMap<String, SessionState>>,
89 broadcast_tx: broadcast::Sender<WatchEvent>,
90 projects_dir: PathBuf,
91}
92
93impl WatchManager {
94 pub fn new(projects_dir: PathBuf) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
95 let (broadcast_tx, _) = broadcast::channel(1000);
96 let active_sessions = Arc::new(DashMap::new());
97
98 let tx_clone = broadcast_tx.clone();
99 let sessions_clone = active_sessions.clone();
100 let projects_dir_clone = projects_dir.clone();
101
102 let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
103 if let Ok(event) = res {
104 if let Err(e) =
105 Self::handle_fs_event(event, &tx_clone, &sessions_clone, &projects_dir_clone)
106 {
107 eprintln!("Error handling file system event: {}", e);
108 }
109 }
110 })?;
111
112 watcher.watch(&projects_dir, RecursiveMode::Recursive)?;
113
114 Ok(WatchManager {
115 _watcher: watcher,
116 active_sessions,
117 broadcast_tx,
118 projects_dir,
119 })
120 }
121
122 fn handle_fs_event(
123 event: Event,
124 broadcast_tx: &broadcast::Sender<WatchEvent>,
125 active_sessions: &DashMap<String, SessionState>,
126 _projects_dir: &std::path::Path,
127 ) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
128 match event.kind {
129 EventKind::Create(_) | EventKind::Modify(_) => {
130 for path in event.paths {
131 if path.extension().is_some_and(|ext| ext == "jsonl") {
132 if let Some(project_name) = path
133 .parent()
134 .and_then(|p| p.file_name())
135 .and_then(|n| n.to_str())
136 {
137 let session_id = path
138 .file_stem()
139 .and_then(|s| s.to_str())
140 .unwrap_or("unknown")
141 .to_string();
142
143 if let Ok(metadata) = fs::metadata(&path) {
145 let key = format!("{}:{}", project_name, session_id);
146 let current_pos =
147 if let Some(session_state) = active_sessions.get(&key) {
148 session_state.last_position
149 } else {
150 0
151 };
152
153 if let Ok(entries) = Self::read_new_entries(&path, current_pos) {
154 active_sessions.insert(
156 key,
157 SessionState {
158 project_name: project_name.to_string(),
159 session_file: path.clone(),
160 last_position: metadata.len(),
161 last_modified: metadata
162 .modified()
163 .unwrap_or(SystemTime::now()),
164 },
165 );
166
167 let max_entries_per_event = 10;
169 for entry in entries.into_iter().take(max_entries_per_event) {
170 let watch_event = WatchEvent {
171 event_type: "log_entry".to_string(),
172 project: project_name.to_string(),
173 session: Some(session_id.clone()),
174 entry: Some(entry),
175 timestamp: Utc::now(),
176 };
177
178 if broadcast_tx.send(watch_event).is_err() {
179 break;
181 }
182 }
183 }
184 }
185 }
186 }
187 }
188 }
189 _ => {}
190 }
191 Ok(())
192 }
193
194 fn read_new_entries(
195 path: &PathBuf,
196 from_position: u64,
197 ) -> Result<Vec<LogEntry>, Box<dyn std::error::Error + Send + Sync>> {
198 let content = match fs::read_to_string(path) {
200 Ok(content) => content,
201 Err(e) => {
202 eprintln!("Warning: Could not read file {}: {}", path.display(), e);
203 return Ok(Vec::new());
204 }
205 };
206
207 let mut entries = Vec::new();
208 let mut current_pos = 0u64;
209
210 for line in content.lines() {
211 let line_end = current_pos + line.len() as u64 + 1; if current_pos >= from_position {
214 if line.trim().starts_with('{') && line.trim().ends_with('}') {
216 if let Ok(entry) = serde_json::from_str::<LogEntry>(line) {
217 entries.push(entry);
218 }
219 }
220 }
221
222 current_pos = line_end;
223 }
224
225 Ok(entries)
226 }
227
228 pub fn subscribe(&self) -> broadcast::Receiver<WatchEvent> {
229 self.broadcast_tx.subscribe()
230 }
231}
232
233#[derive(Debug, Clone)]
234pub struct AppState {
235 pub projects_dir: PathBuf,
236 pub cached_projects: Arc<tokio::sync::RwLock<Vec<ProjectSummary>>>,
237 pub watch_manager: Arc<WatchManager>,
238}
239
240impl AppState {
241 pub fn new(projects_dir: PathBuf) -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
242 let watch_manager = Arc::new(WatchManager::new(projects_dir.clone())?);
243
244 Ok(Self {
245 projects_dir,
246 cached_projects: Arc::new(tokio::sync::RwLock::new(Vec::new())),
247 watch_manager,
248 })
249 }
250
251 async fn refresh_cache(&self) -> Result<(), Box<dyn std::error::Error>> {
252 let mut projects = Vec::new();
253
254 for entry in WalkDir::new(&self.projects_dir).min_depth(1).max_depth(1) {
255 let entry = entry?;
256 if entry.file_type().is_dir() {
257 let project_name = entry.file_name().to_string_lossy().to_string();
258 let project_path = entry.path().to_string_lossy().to_string();
259
260 let mut session_count = 0;
261 let mut latest_activity: Option<DateTime<Utc>> = None;
262
263 for log_entry in WalkDir::new(entry.path()).min_depth(1).max_depth(1) {
264 let log_entry = log_entry?;
265 if log_entry.file_type().is_file()
266 && log_entry
267 .path()
268 .extension()
269 .is_some_and(|ext| ext == "jsonl")
270 {
271 session_count += 1;
272
273 if let Ok(content) = fs::read_to_string(log_entry.path()) {
274 for line in content.lines().take(5) {
275 if let Ok(entry) = serde_json::from_str::<LogEntry>(line) {
276 if let Some(timestamp) = entry.timestamp {
277 match latest_activity {
278 None => latest_activity = Some(timestamp),
279 Some(latest) if timestamp > latest => {
280 latest_activity = Some(timestamp)
281 }
282 _ => {}
283 }
284 }
285 }
286 }
287 }
288 }
289 }
290
291 projects.push(ProjectSummary {
292 name: project_name,
293 path: project_path,
294 session_count,
295 latest_activity,
296 });
297 }
298 }
299
300 projects.sort_by(|a, b| b.latest_activity.cmp(&a.latest_activity));
301
302 *self.cached_projects.write().await = projects;
303 Ok(())
304 }
305}
306
307pub async fn index() -> Html<&'static str> {
308 Html(include_str!("../static/index.html"))
309}
310
311pub async fn live_activity() -> Html<&'static str> {
312 Html(include_str!("../static/live.html"))
313}
314
315pub async fn get_projects(
316 State(state): State<AppState>,
317) -> Result<Json<Vec<ProjectSummary>>, StatusCode> {
318 if let Err(e) = state.refresh_cache().await {
319 eprintln!("Failed to refresh project cache: {}", e);
320 return Err(StatusCode::INTERNAL_SERVER_ERROR);
321 }
322
323 let projects = state.cached_projects.read().await;
324 Ok(Json(projects.clone()))
325}
326
327pub async fn get_sessions(
328 Path(project_name): Path<String>,
329 State(state): State<AppState>,
330) -> Result<Json<Vec<SessionSummary>>, StatusCode> {
331 let project_path = state.projects_dir.join(&project_name);
332
333 if !project_path.exists() {
334 return Err(StatusCode::NOT_FOUND);
335 }
336
337 let mut sessions = Vec::new();
338
339 for entry in WalkDir::new(&project_path).min_depth(1).max_depth(1) {
340 let entry = entry.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
341 if entry.file_type().is_file() && entry.path().extension().is_some_and(|ext| ext == "jsonl")
342 {
343 let session_id = entry
344 .path()
345 .file_stem()
346 .unwrap_or_default()
347 .to_string_lossy()
348 .to_string();
349
350 if let Ok(content) = fs::read_to_string(entry.path()) {
351 let mut summary = "Untitled Session".to_string();
352 let mut timestamp = Utc::now();
353 let message_count = content.lines().count();
354
355 for line in content.lines().take(10) {
356 if let Ok(entry) = serde_json::from_str::<LogEntry>(line) {
357 if entry.entry_type.as_deref() == Some("summary") {
358 if let Some(s) = entry.summary {
359 summary = s;
360 }
361 }
362 if let Some(ts) = entry.timestamp {
363 timestamp = ts;
364 break;
365 }
366 }
367 }
368
369 sessions.push(SessionSummary {
370 id: session_id,
371 summary,
372 timestamp,
373 message_count,
374 project_name: project_name.clone(),
375 });
376 }
377 }
378 }
379
380 sessions.sort_by(|a, b| b.timestamp.cmp(&a.timestamp));
381 Ok(Json(sessions))
382}
383
384pub async fn get_session_logs(
385 Path((project_name, session_id)): Path<(String, String)>,
386 State(state): State<AppState>,
387) -> Result<Json<Vec<LogEntry>>, StatusCode> {
388 let log_path = state
389 .projects_dir
390 .join(&project_name)
391 .join(format!("{}.jsonl", session_id));
392
393 if !log_path.exists() {
394 return Err(StatusCode::NOT_FOUND);
395 }
396
397 let content = fs::read_to_string(&log_path).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
398
399 let mut entries = Vec::new();
400 for line in content.lines() {
401 if let Ok(entry) = serde_json::from_str::<LogEntry>(line) {
402 entries.push(entry);
403 }
404 }
405
406 Ok(Json(entries))
407}
408
409pub async fn websocket_handler(ws: WebSocketUpgrade, State(state): State<AppState>) -> Response {
410 ws.on_upgrade(|socket| handle_websocket(socket, state))
411}
412
413async fn handle_websocket(socket: WebSocket, state: AppState) {
414 let (mut sender, mut receiver) = socket.split();
415 let mut watch_rx = state.watch_manager.subscribe();
416
417 let recv_task = tokio::spawn(async move {
419 while let Some(msg) = receiver.next().await {
420 match msg {
421 Ok(Message::Text(text)) => {
422 println!("Received WebSocket message: {}", text);
423 }
425 Ok(Message::Close(_)) => {
426 println!("WebSocket connection closed");
427 break;
428 }
429 Err(e) => {
430 eprintln!("WebSocket error: {}", e);
431 break;
432 }
433 _ => {}
434 }
435 }
436 });
437
438 let send_task = tokio::spawn(async move {
440 while let Ok(watch_event) = watch_rx.recv().await {
441 let json_msg = match serde_json::to_string(&watch_event) {
442 Ok(json) => json,
443 Err(e) => {
444 eprintln!("Failed to serialize watch event: {}", e);
445 continue;
446 }
447 };
448
449 if sender.send(Message::Text(json_msg)).await.is_err() {
450 break;
451 }
452 }
453 });
454
455 tokio::select! {
457 _ = recv_task => {},
458 _ = send_task => {},
459 }
460}