diff --git a/packages/cli/src/config.rs b/packages/cli/src/config.rs index 873dc4768..dc8b6dfee 100644 --- a/packages/cli/src/config.rs +++ b/packages/cli/src/config.rs @@ -103,6 +103,9 @@ pub struct Advanced { #[serde_as(as = "Option")] pub build_dequeue_timeout: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub collect_diagnostics: Option, + /// Options for rendering error traces. #[serde(default, skip_serializing_if = "Option::is_none")] pub error_trace_options: Option, diff --git a/packages/cli/src/health.rs b/packages/cli/src/health.rs index 72ec9bde2..b9df1b9e3 100644 --- a/packages/cli/src/health.rs +++ b/packages/cli/src/health.rs @@ -1,18 +1,56 @@ use crate::Cli; +use crossterm::style::Stylize as _; use tangram_client::{self as tg, Handle as _}; /// Get the server's health. #[derive(Clone, Debug, clap::Args)] #[group(skip)] -pub struct Args {} +pub struct Args { + // Format as JSON. + #[arg(long)] + pub json: bool, +} impl Cli { - pub async fn command_health(&self, _args: Args) -> tg::Result<()> { + pub async fn command_health(&self, args: Args) -> tg::Result<()> { let handle = self.handle().await?; let health = handle.health().await?; - let health = serde_json::to_string_pretty(&health) - .map_err(|source| tg::error!(!source, "failed to serialize"))?; - println!("{health}"); + if args.json { + let health = serde_json::to_string_pretty(&health) + .map_err(|source| tg::error!(!source, "failed to serialize"))?; + println!("{health}"); + return Ok(()); + } + + if let Some(version) = &health.version { + println!("{}: {version}", "version".blue()); + } + if let Some(builds) = &health.builds { + println!( + "{}: created:{} dequeued:{} started:{}", + "builds".blue(), + builds.created, + builds.dequeued, + builds.started + ); + } + if let Some(database) = &health.database { + println!( + "{}: available_connections:{}", + "database".blue(), + database.available_connections + ); + } + if let Some(file_descriptor_semaphore) = &health.file_descriptor_semaphore { + println!( + "{}: available_permits:{}", + "files".blue(), + file_descriptor_semaphore.available_permits + ); + } + for diagnostic in &health.diagnostics { + Self::print_diagnostic(diagnostic); + } Ok(()) } } diff --git a/packages/cli/src/lib.rs b/packages/cli/src/lib.rs index 8dce272fd..c08171128 100644 --- a/packages/cli/src/lib.rs +++ b/packages/cli/src/lib.rs @@ -3,7 +3,7 @@ use crossterm::{style::Stylize as _, tty::IsTty as _}; use futures::FutureExt as _; use num::ToPrimitive as _; use std::{fmt::Write as _, path::PathBuf, sync::Mutex, time::Duration}; -use tangram_client::{self as tg, Client}; +use tangram_client::{self as tg, Client, Handle}; use tangram_either::Either; use tangram_server::Server; use tokio::io::AsyncWriteExt as _; @@ -52,7 +52,7 @@ pub struct Cli { before_help = before_help(), disable_help_subcommand = true, name = "tangram", - version = version(), + version = tg::health::version(), )] struct Args { #[command(subcommand)] @@ -76,20 +76,11 @@ struct Args { } fn before_help() -> String { - let version = version(); + let version = tg::health::version(); let logo = include_str!("tangram.ascii").trim_end(); format!("Tangram {version}\n\n{logo}") } -fn version() -> String { - let mut version = env!("CARGO_PKG_VERSION").to_owned(); - if let Some(commit) = option_env!("TANGRAM_CLI_COMMIT_HASH") { - version.push('+'); - version.push_str(commit); - } - version -} - #[derive(Clone, Copy, Debug, Default, clap::ValueEnum, serde::Deserialize, serde::Serialize)] #[serde(rename_all = "snake_case")] enum Mode { @@ -830,6 +821,7 @@ impl Cli { // Run the command. async fn command(&self, command: Command) -> tg::Result<()> { + self.show_diagnostics().await.ok(); match command { Command::Artifact(args) => self.command_artifact(args).boxed(), Command::Blob(args) => self.command_blob(args).boxed(), @@ -872,6 +864,15 @@ impl Cli { .await } + async fn show_diagnostics(&self) -> tg::Result<()> { + let handle = self.handle().await?; + let health = handle.health().await?; + for diagnostic in &health.diagnostics { + Self::print_diagnostic(diagnostic); + } + Ok(()) + } + fn read_config(path: Option) -> tg::Result> { let path = path.unwrap_or_else(|| { PathBuf::from(std::env::var("HOME").unwrap()).join(".config/tangram/config.json") @@ -951,9 +952,9 @@ impl Cli { tg::diagnostic::Severity::Info => "info".blue().bold(), tg::diagnostic::Severity::Hint => "hint".cyan().bold(), }; - let mut string = String::new(); - write!(string, "{title}: ").unwrap(); + eprint!("{title}: {}", diagnostic.message); if let Some(location) = &diagnostic.location { + let mut string = String::new(); let path = location .module .referent diff --git a/packages/client/src/health.rs b/packages/client/src/health.rs index 4837d7300..4073c416f 100644 --- a/packages/client/src/health.rs +++ b/packages/client/src/health.rs @@ -7,6 +7,8 @@ pub struct Health { pub database: Option, pub file_descriptor_semaphore: Option, pub version: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub diagnostics: Vec, } #[derive(Clone, Debug, serde::Deserialize, serde::Serialize)] @@ -44,3 +46,14 @@ impl tg::Client { Ok(output) } } + +/// Return the compiled version string. +#[must_use] +pub fn version() -> String { + let mut version = env!("CARGO_PKG_VERSION").to_owned(); + if let Some(commit) = option_env!("TANGRAM_CLI_COMMIT_HASH") { + version.push('+'); + version.push_str(commit); + } + version +} diff --git a/packages/server/src/config.rs b/packages/server/src/config.rs index 2c3349d88..de4a950f3 100644 --- a/packages/server/src/config.rs +++ b/packages/server/src/config.rs @@ -26,6 +26,7 @@ pub struct Config { #[derive(Clone, Debug)] pub struct Advanced { pub build_dequeue_timeout: Duration, + pub collect_diagnostics: bool, pub error_trace_options: tg::error::TraceOptions, pub file_descriptor_semaphore_size: usize, pub preserve_temp_directories: bool, @@ -174,6 +175,7 @@ impl Default for Advanced { fn default() -> Self { Self { build_dequeue_timeout: Duration::from_secs(3600), + collect_diagnostics: true, error_trace_options: tg::error::TraceOptions { internal: true, reverse: false, diff --git a/packages/server/src/health.rs b/packages/server/src/health.rs index 5912d6f00..318c65ca4 100644 --- a/packages/server/src/health.rs +++ b/packages/server/src/health.rs @@ -5,6 +5,8 @@ use tangram_database::{self as db, prelude::*}; use tangram_either::Either; use tangram_http::{outgoing::response::Ext as _, Incoming, Outgoing}; +mod diagnostics; + impl Server { pub async fn health(&self) -> tg::Result { // Get a database connection. @@ -70,6 +72,7 @@ impl Server { database: Some(database), file_descriptor_semaphore: Some(file_descriptor_semaphore), version: self.config.version.clone(), + diagnostics: self.diagnostics(), }; Ok(health) diff --git a/packages/server/src/health/diagnostics.rs b/packages/server/src/health/diagnostics.rs new file mode 100644 index 000000000..1df8616b2 --- /dev/null +++ b/packages/server/src/health/diagnostics.rs @@ -0,0 +1,52 @@ +use crate::Server; +use tangram_client as tg; + +impl Server { + pub(crate) fn diagnostics(&self) -> Vec { + self.diagnostics.lock().unwrap().clone() + } + + pub(crate) async fn diagnostics_task(&self) -> tg::Result<()> { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + let mut diagnostics = Vec::new(); + + if let Some(diagnostic) = self.try_check_latest_version().await { + diagnostics.push(diagnostic); + } + + *self.diagnostics.lock().unwrap() = diagnostics; + } + } + + async fn try_check_latest_version(&self) -> Option { + let version = tg::health::version(); + #[derive(serde::Deserialize)] + struct Response { + name: String, + } + let response: Response = reqwest::Client::new() + .request( + http::Method::GET, + "https://api.github.com/repos/tangramdotdev/tangram/releases/latest", + ) + .header("Accept", "application/vnd.github+json") + .header("User-Agent", "tangram") + .send() + .await + .inspect_err(|error| tracing::warn!(%error, "failed to get response from github")) + .ok()? + .json() + .await + .inspect_err( + |error| tracing::warn!(%error, "failed to deserialize response from github"), + ) + .ok()?; + (response.name != version).then(|| tg::Diagnostic { + location: None, + severity: tg::diagnostic::Severity::Warning, + message: format!("A new version of tangram is available. Current is {version}, but the latest is {}.", response.name) + }) + } +} diff --git a/packages/server/src/lib.rs b/packages/server/src/lib.rs index 816287315..173f150c9 100644 --- a/packages/server/src/lib.rs +++ b/packages/server/src/lib.rs @@ -71,6 +71,7 @@ pub struct Inner { compilers: RwLock>, config: Config, database: Database, + diagnostics: Mutex>, file_descriptor_semaphore: tokio::sync::Semaphore, local_pool_handle: tokio_util::task::LocalPoolHandle, lock_file: Mutex>, @@ -235,6 +236,9 @@ impl Server { }, }; + // Create the diagnostics. + let diagnostics = Mutex::new(Vec::new()); + // Create the file system semaphore. let file_descriptor_semaphore = tokio::sync::Semaphore::new(config.advanced.file_descriptor_semaphore_size); @@ -287,6 +291,7 @@ impl Server { compilers, config, database, + diagnostics, file_descriptor_semaphore, local_pool_handle, lock_file, @@ -463,6 +468,18 @@ impl Server { None }; + // Spawn the diagnostics task. + let diagnostics_task = server.config.advanced.collect_diagnostics.then(|| { + let server = server.clone(); + tokio::spawn(async move { + server + .diagnostics_task() + .await + .inspect_err(|error| tracing::error!(?error)) + .ok(); + }) + }); + // Spawn the object indexer task. let object_indexer_task = if server.config.object_indexer.is_some() { Some(tokio::spawn({ @@ -539,6 +556,11 @@ impl Server { } } + // Abort the diagnostics task. + if let Some(task) = diagnostics_task { + task.abort(); + } + // Abort the object index task. if let Some(task) = object_indexer_task { task.abort();