Compare commits
2 Commits
jochen_cac
...
micn/conso
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d8ddbe4b9 | ||
|
|
0466405d87 |
256
crates/g3-console/src/logs.rs
Normal file
256
crates/g3-console/src/logs.rs
Normal file
@@ -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<DateTime<Utc>>,
|
||||||
|
pub role: Option<String>,
|
||||||
|
pub content: Option<String>,
|
||||||
|
pub tool_calls: Option<Vec<Value>>,
|
||||||
|
pub raw: Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ChatMessage {
|
||||||
|
pub role: String,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ToolCall {
|
||||||
|
pub name: String,
|
||||||
|
pub parameters: Value,
|
||||||
|
pub result: Option<String>,
|
||||||
|
pub timestamp: Option<DateTime<Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct LogParser;
|
||||||
|
|
||||||
|
impl LogParser {
|
||||||
|
/// Parse logs from a workspace directory
|
||||||
|
pub fn parse_logs(workspace: &Path) -> Result<Vec<LogEntry>> {
|
||||||
|
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::<Value>(&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<ChatMessage> {
|
||||||
|
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<ToolCall> {
|
||||||
|
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<Utc>,
|
||||||
|
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<String> {
|
||||||
|
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<TurnInfo> {
|
||||||
|
// Simple implementation: group consecutive assistant messages as turns
|
||||||
|
let mut turns = Vec::new();
|
||||||
|
let mut current_turn_start: Option<DateTime<Utc>> = 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ use anyhow::Result;
|
|||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, Utc};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use sysinfo::{System, Pid, Process};
|
use sysinfo::{System, Pid, Process};
|
||||||
use tracing::{debug, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
pub struct ProcessDetector {
|
pub struct ProcessDetector {
|
||||||
system: System,
|
system: System,
|
||||||
@@ -17,7 +17,11 @@ impl ProcessDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn detect_instances(&mut self) -> Result<Vec<Instance>> {
|
pub fn detect_instances(&mut self) -> Result<Vec<Instance>> {
|
||||||
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();
|
let mut instances = Vec::new();
|
||||||
|
|
||||||
// Find all g3 processes
|
// Find all g3 processes
|
||||||
@@ -33,7 +37,7 @@ impl ProcessDetector {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
debug!("Detected {} g3 instances", instances.len());
|
info!("Detected {} g3 instances", instances.len());
|
||||||
Ok(instances)
|
Ok(instances)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,24 +49,27 @@ impl ProcessDetector {
|
|||||||
) -> Option<Instance> {
|
) -> Option<Instance> {
|
||||||
let cmd_str = cmd.join(" ");
|
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)
|
// Check if this is a g3 binary (more comprehensive check)
|
||||||
let is_g3_binary = cmd.get(0).map(|s| {
|
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);
|
}).unwrap_or(false);
|
||||||
|
|
||||||
// Check if this is cargo run with g3
|
// 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");
|
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
|
// Also check if command line has g3-specific flags
|
||||||
let has_g3_pattern = cmd_str.contains("g3 ")
|
let has_g3_flags = cmd_str.contains("--workspace") || cmd_str.contains("--autonomous");
|
||||||
|| 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
|
|
||||||
|
|
||||||
// Accept if it's a g3 binary, cargo run with g3 patterns, or has g3-specific flags
|
// 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_pattern) || has_g3_pattern;
|
let is_g3_process = is_g3_binary || (is_cargo_run && has_g3_flags);
|
||||||
|
|
||||||
if !is_g3_process {
|
if !is_g3_process {
|
||||||
return None;
|
return None;
|
||||||
@@ -165,7 +172,7 @@ impl ProcessDetector {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_process_status(&mut self, pid: u32) -> Option<InstanceStatus> {
|
pub fn get_process_status(&mut self, pid: u32) -> Option<InstanceStatus> {
|
||||||
self.system.refresh_processes();
|
self.system.refresh_all();
|
||||||
|
|
||||||
let sysinfo_pid = Pid::from_u32(pid);
|
let sysinfo_pid = Pid::from_u32(pid);
|
||||||
if self.system.process(sysinfo_pid).is_some() {
|
if self.system.process(sysinfo_pid).is_some() {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<div id="app">
|
<div id="app">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<h1 class="header-title">G3 Console</h1>
|
<h1 class="header-title">G3 Console <span id="live-indicator" class="live-indicator" title="Scanning for processes every 3 seconds">● LIVE</span></h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button id="new-run-btn" class="btn btn-primary">+ New Run</button>
|
<button id="new-run-btn" class="btn btn-primary">+ New Run</button>
|
||||||
<button id="theme-toggle" class="btn btn-secondary">🌙</button>
|
<button id="theme-toggle" class="btn btn-secondary">🌙</button>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const router = {
|
|||||||
currentInstanceId: null,
|
currentInstanceId: null,
|
||||||
initialized: false,
|
initialized: false,
|
||||||
renderInProgress: false,
|
renderInProgress: false,
|
||||||
|
REFRESH_INTERVAL_MS: 3000, // Refresh every 3 seconds for live updates
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
console.log('[Router] init() called');
|
console.log('[Router] init() called');
|
||||||
@@ -84,6 +85,9 @@ const router = {
|
|||||||
this.renderInProgress = true;
|
this.renderInProgress = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Flash live indicator
|
||||||
|
this.flashLiveIndicator();
|
||||||
|
|
||||||
// Check if we already have a container for instances
|
// Check if we already have a container for instances
|
||||||
let instancesList = container.querySelector('.instances-list');
|
let instancesList = container.querySelector('.instances-list');
|
||||||
const isInitialLoad = !instancesList;
|
const isInitialLoad = !instancesList;
|
||||||
@@ -167,11 +171,11 @@ const router = {
|
|||||||
|
|
||||||
// Schedule next refresh only if still on home route
|
// Schedule next refresh only if still on home route
|
||||||
if (this.currentRoute === '/' || this.currentRoute === '') {
|
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(() => {
|
this.refreshTimeout = setTimeout(() => {
|
||||||
console.log('[Router] Auto-refresh triggered');
|
console.log('[Router] Auto-refresh triggered');
|
||||||
this.renderHome(container);
|
this.renderHome(container);
|
||||||
}, 5000);
|
}, this.REFRESH_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[Router] Error in renderHome:', 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) {
|
async renderDetail(container, id) {
|
||||||
console.log('[Router] renderDetail called for', id);
|
console.log('[Router] renderDetail called for', id);
|
||||||
|
|
||||||
this.currentInstanceId = id;
|
this.currentInstanceId = id;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Flash live indicator
|
||||||
|
this.flashLiveIndicator();
|
||||||
|
|
||||||
// Check if we already have a detail view for this instance
|
// Check if we already have a detail view for this instance
|
||||||
let detailView = container.querySelector('.detail-view');
|
let detailView = container.querySelector('.detail-view');
|
||||||
const isInitialLoad = !detailView || detailView.getAttribute('data-instance-id') !== id;
|
const isInitialLoad = !detailView || detailView.getAttribute('data-instance-id') !== id;
|
||||||
|
|||||||
@@ -64,6 +64,22 @@ body {
|
|||||||
color: var(--text-primary);
|
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 {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
|
|||||||
Reference in New Issue
Block a user