mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2025-12-11 20:15:30 +01:00
(chore): CI fix
This commit is contained in:
parent
8f6a419774
commit
7477c9d440
1
.gitignore
vendored
1
.gitignore
vendored
@ -87,6 +87,7 @@ next-env.d.ts
|
||||
### Node ###
|
||||
# Logs
|
||||
logs
|
||||
!apps/cli/src/domains/logs/
|
||||
*.log
|
||||
lerna-debug.log*
|
||||
|
||||
|
||||
69
apps/cli/src/domains/logs/args.rs
Normal file
69
apps/cli/src/domains/logs/args.rs
Normal file
@ -0,0 +1,69 @@
|
||||
//! Command line arguments for logs commands
|
||||
|
||||
use clap::{Args, Subcommand};
|
||||
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum LogsCmd {
|
||||
/// Show recent logs
|
||||
Show(LogsShowArgs),
|
||||
/// Follow logs in real-time
|
||||
Follow(LogsFollowArgs),
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct LogsShowArgs {
|
||||
/// Number of lines to show
|
||||
#[arg(short = 'n', long, default_value = "100")]
|
||||
pub lines: usize,
|
||||
|
||||
/// Filter by log level (error, warn, info, debug, trace)
|
||||
#[arg(long)]
|
||||
pub level: Option<String>,
|
||||
|
||||
/// Filter by component/target
|
||||
#[arg(long)]
|
||||
pub component: Option<String>,
|
||||
|
||||
/// Filter by job ID
|
||||
#[arg(long)]
|
||||
pub job_id: Option<String>,
|
||||
|
||||
/// Show timestamps
|
||||
#[arg(short, long)]
|
||||
pub timestamps: bool,
|
||||
|
||||
/// Verbose output (show full target)
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
}
|
||||
|
||||
#[derive(Args, Debug)]
|
||||
pub struct LogsFollowArgs {
|
||||
/// Filter by log level (error, warn, info, debug, trace)
|
||||
#[arg(long)]
|
||||
pub level: Option<String>,
|
||||
|
||||
/// Filter by component/target
|
||||
#[arg(long)]
|
||||
pub component: Option<String>,
|
||||
|
||||
/// Filter by job ID
|
||||
#[arg(long)]
|
||||
pub job_id: Option<String>,
|
||||
|
||||
/// Show timestamps
|
||||
#[arg(short, long)]
|
||||
pub timestamps: bool,
|
||||
|
||||
/// Verbose output (show full target)
|
||||
#[arg(short, long)]
|
||||
pub verbose: bool,
|
||||
|
||||
/// Show job IDs in output
|
||||
#[arg(long)]
|
||||
pub show_job_id: bool,
|
||||
|
||||
/// Show library IDs in output
|
||||
#[arg(long)]
|
||||
pub show_library_id: bool,
|
||||
}
|
||||
354
apps/cli/src/domains/logs/mod.rs
Normal file
354
apps/cli/src/domains/logs/mod.rs
Normal file
@ -0,0 +1,354 @@
|
||||
//! Logs domain for viewing and following daemon logs
|
||||
|
||||
mod args;
|
||||
|
||||
pub use args::*;
|
||||
|
||||
use crate::context::Context;
|
||||
use anyhow::Result;
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
/// Run logs command
|
||||
pub async fn run(ctx: &Context, cmd: LogsCmd) -> Result<()> {
|
||||
match cmd {
|
||||
LogsCmd::Show(args) => run_logs_show(ctx, args).await,
|
||||
LogsCmd::Follow(args) => run_logs_follow(ctx, args).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Show recent logs
|
||||
async fn run_logs_show(ctx: &Context, args: LogsShowArgs) -> Result<()> {
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
|
||||
println!("Recent logs (last {} lines)", args.lines);
|
||||
|
||||
// Get the daemon log file path
|
||||
let log_file_path = get_daemon_log_path(ctx).await?;
|
||||
|
||||
if !log_file_path.exists() {
|
||||
println!("No log file found at: {}", log_file_path.display());
|
||||
println!("Start the daemon to begin logging");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Read the last N lines from the log file
|
||||
let lines = read_last_lines(&log_file_path, args.lines)?;
|
||||
|
||||
for line in lines {
|
||||
if let Some(formatted) = format_log_line(&line, &args) {
|
||||
println!("{}", formatted);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Follow logs in real-time
|
||||
async fn run_logs_follow(ctx: &Context, args: LogsFollowArgs) -> Result<()> {
|
||||
use sd_core::infra::daemon::types::EventFilter;
|
||||
use sd_core::infra::event::Event;
|
||||
|
||||
println!("Following logs in real-time - Press Ctrl+C to exit");
|
||||
if let Some(ref level) = args.level {
|
||||
println!("Filtering by level: {}", level);
|
||||
}
|
||||
if let Some(ref component) = args.component {
|
||||
println!("Filtering by component: {}", component);
|
||||
}
|
||||
if let Some(ref job_id) = args.job_id {
|
||||
println!("Filtering by job: {}", job_id);
|
||||
}
|
||||
println!("═══════════════════════════════════════════════════════");
|
||||
|
||||
// First, show recent historical logs
|
||||
show_recent_historical_logs(ctx, &args).await?;
|
||||
|
||||
// Subscribe to log events
|
||||
let event_types = vec!["LogMessage".to_string()];
|
||||
|
||||
let filter = EventFilter {
|
||||
library_id: None,
|
||||
job_id: args.job_id.clone(),
|
||||
device_id: None,
|
||||
};
|
||||
|
||||
// Try to subscribe to events, fall back to polling if not supported
|
||||
match ctx.core.subscribe_events(event_types, Some(filter)).await {
|
||||
Ok(mut event_stream) => {
|
||||
println!("Connected to real-time log stream");
|
||||
|
||||
// Listen for log events
|
||||
while let Some(event) = event_stream.recv().await {
|
||||
if let Event::LogMessage {
|
||||
timestamp,
|
||||
level,
|
||||
target,
|
||||
message,
|
||||
job_id,
|
||||
library_id,
|
||||
} = event
|
||||
{
|
||||
// Apply client-side filters
|
||||
if let Some(ref filter_level) = args.level {
|
||||
if !level_matches(&level, filter_level) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref filter_component) = args.component {
|
||||
if !target.contains(filter_component) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Format and display the log message
|
||||
let formatted_time = format_timestamp(timestamp, args.timestamps);
|
||||
let level_colored = colorize_level(&level);
|
||||
let target_formatted = if args.verbose {
|
||||
format!(" {}", target)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let job_info = if args.show_job_id {
|
||||
job_id
|
||||
.as_ref()
|
||||
.map(|id| format!(" [job:{}]", &id[..8]))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let library_info = if args.show_library_id {
|
||||
library_id
|
||||
.as_ref()
|
||||
.map(|id| format!(" [lib:{}]", &id.to_string()[..8]))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
println!(
|
||||
"{}{}{}{}{} {}",
|
||||
formatted_time,
|
||||
level_colored,
|
||||
target_formatted,
|
||||
job_info,
|
||||
library_info,
|
||||
message
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(_) => {
|
||||
println!("Real-time log streaming not available");
|
||||
println!("Make sure the daemon is running with event streaming support");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if a log level matches the filter
|
||||
fn level_matches(log_level: &str, filter_level: &str) -> bool {
|
||||
let level_priority = |level: &str| match level.to_uppercase().as_str() {
|
||||
"ERROR" => 0,
|
||||
"WARN" => 1,
|
||||
"INFO" => 2,
|
||||
"DEBUG" => 3,
|
||||
"TRACE" => 4,
|
||||
_ => 5,
|
||||
};
|
||||
|
||||
level_priority(log_level) <= level_priority(filter_level)
|
||||
}
|
||||
|
||||
/// Format timestamp based on user preference
|
||||
fn format_timestamp(timestamp: DateTime<Utc>, show_timestamps: bool) -> String {
|
||||
if show_timestamps {
|
||||
format!("[{}] ", timestamp.format("%H:%M:%S%.3f"))
|
||||
} else {
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// Colorize log level for better readability
|
||||
fn colorize_level(level: &str) -> String {
|
||||
match level.to_uppercase().as_str() {
|
||||
"ERROR" => format!("{:<5}", level),
|
||||
"WARN" => format!(" {:<5}", level),
|
||||
"INFO" => format!(" {:<5}", level),
|
||||
"DEBUG" => format!("{:<5}", level),
|
||||
"TRACE" => format!("{:<5}", level),
|
||||
_ => format!(" {:<5}", level),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the daemon log file path by querying the daemon's data directory
|
||||
async fn get_daemon_log_path(_ctx: &Context) -> Result<std::path::PathBuf> {
|
||||
// Try to get the data directory from the daemon
|
||||
// For now, use the standard location
|
||||
let home_dir = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
|
||||
let data_dir =
|
||||
std::path::PathBuf::from(home_dir).join("Library/Application Support/spacedrive");
|
||||
|
||||
let logs_dir = data_dir.join("logs");
|
||||
|
||||
// Check for today's log file first (with date suffix due to daily rotation)
|
||||
let today = chrono::Utc::now().format("%Y-%m-%d").to_string();
|
||||
let today_log = logs_dir.join(format!("daemon.log.{}", today));
|
||||
|
||||
if today_log.exists() {
|
||||
return Ok(today_log);
|
||||
}
|
||||
|
||||
// Fall back to the base log file name
|
||||
let base_log = logs_dir.join("daemon.log");
|
||||
if base_log.exists() {
|
||||
return Ok(base_log);
|
||||
}
|
||||
|
||||
// If neither exists, find the most recent log file
|
||||
if let Ok(entries) = std::fs::read_dir(&logs_dir) {
|
||||
let mut log_files: Vec<_> = entries
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("daemon.log")
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by modification time, most recent first
|
||||
log_files.sort_by(|a, b| {
|
||||
let a_time = a
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
let b_time = b
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(std::time::UNIX_EPOCH);
|
||||
b_time.cmp(&a_time)
|
||||
});
|
||||
|
||||
if let Some(most_recent) = log_files.first() {
|
||||
return Ok(most_recent.path());
|
||||
}
|
||||
}
|
||||
|
||||
// Default to today's log file path even if it doesn't exist yet
|
||||
Ok(today_log)
|
||||
}
|
||||
|
||||
/// Read the last N lines from a file efficiently
|
||||
fn read_last_lines(file_path: &std::path::Path, n: usize) -> Result<Vec<String>> {
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader, Seek, SeekFrom};
|
||||
|
||||
let file = File::open(file_path)?;
|
||||
let mut reader = BufReader::new(file);
|
||||
|
||||
// For simplicity, read all lines and take the last N
|
||||
// TODO: Optimize for large files by reading from the end
|
||||
let lines: Result<Vec<String>, _> = reader.lines().collect();
|
||||
let all_lines = lines?;
|
||||
|
||||
let start_idx = if all_lines.len() > n {
|
||||
all_lines.len() - n
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
Ok(all_lines[start_idx..].to_vec())
|
||||
}
|
||||
|
||||
/// Format a log line based on user preferences
|
||||
fn format_log_line(line: &str, args: &LogsShowArgs) -> Option<String> {
|
||||
// Parse the log line format: timestamp LEVEL ThreadId(N) target: message
|
||||
// Example: 2025-09-19T02:25:54.897283Z DEBUG ThreadId(13) sd_core::infra::event: Event emitted to subscribers
|
||||
|
||||
let parts: Vec<&str> = line.splitn(5, ' ').collect();
|
||||
if parts.len() < 5 {
|
||||
return Some(line.to_string()); // Return as-is if we can't parse
|
||||
}
|
||||
|
||||
let timestamp = parts[0];
|
||||
let level = parts[1];
|
||||
let _thread_id = parts[2]; // ThreadId(N)
|
||||
let target = parts[3].trim_end_matches(':');
|
||||
let message = parts[4];
|
||||
|
||||
// Apply filters
|
||||
if let Some(ref filter_level) = args.level {
|
||||
if !level_matches(level, filter_level) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref filter_component) = args.component {
|
||||
if !target.contains(filter_component) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
// Format output
|
||||
let formatted_time = if args.timestamps {
|
||||
format!("[{}] ", timestamp)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let level_colored = colorize_level(level);
|
||||
|
||||
let target_formatted = if args.verbose {
|
||||
format!(" {}", target)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Some(format!(
|
||||
"{}{}{} {}",
|
||||
formatted_time, level_colored, target_formatted, message
|
||||
))
|
||||
}
|
||||
|
||||
/// Show recent historical logs before starting real-time streaming
|
||||
async fn show_recent_historical_logs(ctx: &Context, args: &LogsFollowArgs) -> Result<()> {
|
||||
let log_file_path = get_daemon_log_path(ctx).await?;
|
||||
|
||||
if !log_file_path.exists() {
|
||||
println!("No historical logs found");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
println!("Recent logs:");
|
||||
println!("───────────────");
|
||||
|
||||
// Show last 2lines of historical logs
|
||||
let lines = read_last_lines(&log_file_path, 20)?;
|
||||
|
||||
for line in lines {
|
||||
// Convert LogsFollowArgs to LogsShowArgs for formatting
|
||||
let show_args = LogsShowArgs {
|
||||
lines: 20,
|
||||
level: args.level.clone(),
|
||||
component: args.component.clone(),
|
||||
job_id: args.job_id.clone(),
|
||||
timestamps: args.timestamps,
|
||||
verbose: args.verbose,
|
||||
};
|
||||
|
||||
if let Some(formatted) = format_log_line(&line, &show_args) {
|
||||
println!("{}", formatted);
|
||||
}
|
||||
}
|
||||
|
||||
println!("───────────────");
|
||||
println!("Now streaming live logs...");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user