diff --git a/crates/g3-console/src/logs.rs b/crates/g3-console/src/logs.rs new file mode 100644 index 0000000..59207e7 --- /dev/null +++ b/crates/g3-console/src/logs.rs @@ -0,0 +1,256 @@ +use crate::models::{InstanceStats, TurnInfo}; +use anyhow::{Context, Result}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::fs; +use std::path::Path; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LogEntry { + pub timestamp: Option>, + pub role: Option, + pub content: Option, + pub tool_calls: Option>, + pub raw: Value, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ChatMessage { + pub role: String, + pub content: String, + pub timestamp: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ToolCall { + pub name: String, + pub parameters: Value, + pub result: Option, + pub timestamp: Option>, +} + +pub struct LogParser; + +impl LogParser { + /// Parse logs from a workspace directory + pub fn parse_logs(workspace: &Path) -> Result> { + let logs_dir = workspace.join("logs"); + + if !logs_dir.exists() { + return Ok(Vec::new()); + } + + let mut entries = Vec::new(); + + // Read all JSON log files + for entry in fs::read_dir(&logs_dir).context("Failed to read logs directory")? { + let entry = entry?; + let path = entry.path(); + + if path.extension().and_then(|s| s.to_str()) == Some("json") { + if let Ok(content) = fs::read_to_string(&path) { + if let Ok(json) = serde_json::from_str::(&content) { + // Try to parse as a log session + if let Some(messages) = json.get("messages").and_then(|m| m.as_array()) { + for msg in messages { + entries.push(LogEntry { + timestamp: msg.get("timestamp") + .and_then(|t| t.as_str()) + .and_then(|s| DateTime::parse_from_rfc3339(s).ok()) + .map(|dt| dt.with_timezone(&Utc)), + role: msg.get("role") + .and_then(|r| r.as_str()) + .map(String::from), + content: msg.get("content") + .and_then(|c| c.as_str()) + .map(String::from), + tool_calls: msg.get("tool_calls") + .and_then(|tc| tc.as_array()) + .map(|arr| arr.clone()), + raw: msg.clone(), + }); + } + } + } + } + } + } + + // Sort by timestamp + entries.sort_by(|a, b| { + match (&a.timestamp, &b.timestamp) { + (Some(t1), Some(t2)) => t1.cmp(t2), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } + }); + + Ok(entries) + } + + /// Extract chat messages from log entries + pub fn extract_chat_messages(entries: &[LogEntry]) -> Vec { + entries + .iter() + .filter_map(|entry| { + let role = entry.role.clone()?; + let content = entry.content.clone()?; + + Some(ChatMessage { + role, + content, + timestamp: entry.timestamp, + }) + }) + .collect() + } + + /// Extract tool calls from log entries + pub fn extract_tool_calls(entries: &[LogEntry]) -> Vec { + let mut tool_calls = Vec::new(); + + for entry in entries { + if let Some(calls) = &entry.tool_calls { + for call in calls { + if let Some(name) = call.get("name").and_then(|n| n.as_str()) { + tool_calls.push(ToolCall { + name: name.to_string(), + parameters: call.get("parameters") + .cloned() + .unwrap_or(Value::Object(serde_json::Map::new())), + result: call.get("result") + .and_then(|r| r.as_str()) + .map(String::from), + timestamp: entry.timestamp, + }); + } + } + } + } + + tool_calls + } +} + +pub struct StatsAggregator; + +impl StatsAggregator { + /// Aggregate statistics from log entries + pub fn aggregate_stats( + entries: &[LogEntry], + start_time: DateTime, + is_ensemble: bool, + ) -> InstanceStats { + let total_tokens = Self::count_tokens(entries); + let tool_calls = Self::count_tool_calls(entries); + let errors = Self::count_errors(entries); + + let duration_secs = if let Some(last_entry) = entries.last() { + if let Some(last_time) = last_entry.timestamp { + (last_time - start_time).num_seconds().max(0) as u64 + } else { + (Utc::now() - start_time).num_seconds().max(0) as u64 + } + } else { + (Utc::now() - start_time).num_seconds().max(0) as u64 + }; + + let turns = if is_ensemble { + Some(Self::extract_turns(entries)) + } else { + None + }; + + InstanceStats { + total_tokens, + tool_calls, + errors, + duration_secs, + turns, + } + } + + /// Get the latest message content from log entries + pub fn get_latest_message(entries: &[LogEntry]) -> Option { + entries + .iter() + .rev() + .find(|entry| entry.role.as_deref() == Some("assistant")) + .and_then(|entry| entry.content.clone()) + .or_else(|| { + entries + .iter() + .rev() + .find(|entry| entry.content.is_some()) + .and_then(|entry| entry.content.clone()) + }) + } + + fn count_tokens(entries: &[LogEntry]) -> u64 { + // Try to extract token counts from metadata + entries + .iter() + .filter_map(|entry| { + entry.raw.get("usage") + .and_then(|u| u.get("total_tokens")) + .and_then(|t| t.as_u64()) + }) + .sum() + } + + fn count_tool_calls(entries: &[LogEntry]) -> u64 { + entries + .iter() + .filter_map(|entry| entry.tool_calls.as_ref()) + .map(|calls| calls.len() as u64) + .sum() + } + + fn count_errors(entries: &[LogEntry]) -> u64 { + entries + .iter() + .filter(|entry| { + entry.raw.get("error").is_some() + || entry.content.as_ref().map(|c| c.to_lowercase().contains("error")).unwrap_or(false) + }) + .count() as u64 + } + + fn extract_turns(entries: &[LogEntry]) -> Vec { + // Simple implementation: group consecutive assistant messages as turns + let mut turns = Vec::new(); + let mut current_turn_start: Option> = None; + let mut turn_count = 0; + + for entry in entries { + if entry.role.as_deref() == Some("assistant") { + if current_turn_start.is_none() { + current_turn_start = entry.timestamp; + turn_count += 1; + } + } else if entry.role.as_deref() == Some("user") { + if let Some(start) = current_turn_start { + if let Some(end) = entry.timestamp { + let duration = (end - start).num_seconds().max(0) as u64; + turns.push(TurnInfo { + agent: format!("agent-{}", turn_count), + duration_secs: duration, + status: "completed".to_string(), + color: Self::get_turn_color(turn_count), + }); + } + current_turn_start = None; + } + } + } + + turns + } + + fn get_turn_color(turn_number: usize) -> String { + let colors = vec!["blue", "green", "purple", "orange", "pink", "teal"]; + colors[turn_number % colors.len()].to_string() + } +} diff --git a/crates/g3-console/src/process/detector.rs b/crates/g3-console/src/process/detector.rs index 5418af2..9b488f7 100644 --- a/crates/g3-console/src/process/detector.rs +++ b/crates/g3-console/src/process/detector.rs @@ -3,7 +3,7 @@ use anyhow::Result; use chrono::{DateTime, Utc}; use std::path::PathBuf; use sysinfo::{System, Pid, Process}; -use tracing::{debug, warn}; +use tracing::{debug, info, warn}; pub struct ProcessDetector { system: System, @@ -17,7 +17,11 @@ impl ProcessDetector { } pub fn detect_instances(&mut self) -> Result> { - self.system.refresh_processes(); + info!("Scanning for g3 processes..."); + // Refresh all processes to ensure we catch newly started ones + // Using refresh_all() instead of just refresh_processes() to ensure + // we get complete information about new processes + self.system.refresh_all(); let mut instances = Vec::new(); // Find all g3 processes @@ -33,7 +37,7 @@ impl ProcessDetector { } } - debug!("Detected {} g3 instances", instances.len()); + info!("Detected {} g3 instances", instances.len()); Ok(instances) } @@ -45,24 +49,27 @@ impl ProcessDetector { ) -> Option { let cmd_str = cmd.join(" "); + // Exclude g3-console itself + if cmd_str.contains("g3-console") { + return None; + } + // Check if this is a g3 binary (more comprehensive check) let is_g3_binary = cmd.get(0).map(|s| { - s.ends_with("g3") || s.ends_with("/g3") || s.contains("/target/release/g3") || s.contains("/target/debug/g3") + (s.ends_with("g3") || s.ends_with("/g3") || s.contains("/target/release/g3") || s.contains("/target/debug/g3")) + && !s.contains("g3-") // Exclude other g3-* binaries }).unwrap_or(false); - // Check if this is cargo run with g3 - let is_cargo_run = cmd.get(0).map(|s| s.contains("cargo")).unwrap_or(false) && cmd.iter().any(|s| s == "run"); + // Check if this is cargo run with g3 (not g3-console or other variants) + let is_cargo_run = cmd.get(0).map(|s| s.contains("cargo")).unwrap_or(false) + && cmd.iter().any(|s| s == "run") + && !cmd_str.contains("g3-console"); - // Also check if any part of the command line contains g3-related patterns - let has_g3_pattern = cmd_str.contains("g3 ") - || cmd_str.contains("/g3 ") - || cmd_str.contains("g3-") - || cmd_str.ends_with("g3") - || cmd_str.contains("--workspace") // g3-specific flag - || cmd_str.contains("--autonomous"); // g3-specific flag + // Also check if command line has g3-specific flags + let has_g3_flags = cmd_str.contains("--workspace") || cmd_str.contains("--autonomous"); - // Accept if it's a g3 binary, cargo run with g3 patterns, or has g3-specific flags - let is_g3_process = is_g3_binary || (is_cargo_run && has_g3_pattern) || has_g3_pattern; + // Accept if it's a g3 binary or cargo run with g3, and has typical g3 patterns + let is_g3_process = is_g3_binary || (is_cargo_run && has_g3_flags); if !is_g3_process { return None; @@ -165,7 +172,7 @@ impl ProcessDetector { } pub fn get_process_status(&mut self, pid: u32) -> Option { - self.system.refresh_processes(); + self.system.refresh_all(); let sysinfo_pid = Pid::from_u32(pid); if self.system.process(sysinfo_pid).is_some() { diff --git a/crates/g3-console/web/index.html b/crates/g3-console/web/index.html index 4f9cfb3..53c9052 100644 --- a/crates/g3-console/web/index.html +++ b/crates/g3-console/web/index.html @@ -15,7 +15,7 @@
-

G3 Console

+

G3 Console ● LIVE

diff --git a/crates/g3-console/web/js/router.js b/crates/g3-console/web/js/router.js index 66a52ff..cfe77a5 100644 --- a/crates/g3-console/web/js/router.js +++ b/crates/g3-console/web/js/router.js @@ -6,6 +6,7 @@ const router = { currentInstanceId: null, initialized: false, renderInProgress: false, + REFRESH_INTERVAL_MS: 3000, // Refresh every 3 seconds for live updates init() { console.log('[Router] init() called'); @@ -84,6 +85,9 @@ const router = { this.renderInProgress = true; try { + // Flash live indicator + this.flashLiveIndicator(); + // Check if we already have a container for instances let instancesList = container.querySelector('.instances-list'); const isInitialLoad = !instancesList; @@ -167,11 +171,11 @@ const router = { // Schedule next refresh only if still on home route if (this.currentRoute === '/' || this.currentRoute === '') { - console.log('[Router] Scheduling auto-refresh in 5 seconds'); + console.log(`[Router] Scheduling auto-refresh in ${this.REFRESH_INTERVAL_MS}ms`); this.refreshTimeout = setTimeout(() => { console.log('[Router] Auto-refresh triggered'); this.renderHome(container); - }, 5000); + }, this.REFRESH_INTERVAL_MS); } } catch (error) { console.error('[Router] Error in renderHome:', error); @@ -187,12 +191,26 @@ const router = { } }, + flashLiveIndicator() { + const indicator = document.getElementById('live-indicator'); + if (indicator) { + indicator.style.animation = 'none'; + // Force reflow + void indicator.offsetWidth; + indicator.style.animation = null; + indicator.style.opacity = '1'; + } + }, + async renderDetail(container, id) { console.log('[Router] renderDetail called for', id); this.currentInstanceId = id; try { + // Flash live indicator + this.flashLiveIndicator(); + // Check if we already have a detail view for this instance let detailView = container.querySelector('.detail-view'); const isInitialLoad = !detailView || detailView.getAttribute('data-instance-id') !== id; diff --git a/crates/g3-console/web/styles/app.css b/crates/g3-console/web/styles/app.css index 1fb8e3a..ee58385 100644 --- a/crates/g3-console/web/styles/app.css +++ b/crates/g3-console/web/styles/app.css @@ -64,6 +64,22 @@ body { color: var(--text-primary); } +.live-indicator { + font-size: 0.625rem; /* 75% of 0.833rem */ + font-weight: 600; + color: var(--success); + margin-left: 0.75rem; + display: inline-flex; + align-items: center; + gap: 0.25rem; + animation: pulse 2s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + .header-actions { display: flex; gap: 1rem;