feat(logging): introduce console usage linting and enhance error logging structure across components for improved diagnostics

This commit is contained in:
Kang committed 2026-04-20 21:39:55 +08:00
1 parent 517583fbf1
commit a37090e70d
57 files changed
+2629 -389

No files matched your search

+3 -2
View File
@@ -14,13 +14,14 @@
},
"type": "module",
"scripts": {
"lint:console": "node scripts/check-no-console.mjs",
"version-sync": "node scripts/sync-version.mjs",
"dev": "vite",
"build": "tsc && vite build",
"release": "node scripts/sync-version.mjs --commit && tsc && vite build && tauri build",
"preview": "vite preview",
"tauri": "tauri",
"lint": "biome check src/",
"lint": "node scripts/check-no-console.mjs && biome check src/",
"format": "biome check --write src/",
"format:check": "biome check --check src/",
"i18n:check": "prettier --check src/i18n/locales/*.json",
@@ -72,4 +73,4 @@
"typescript": "~5.8.3",
"vite": "^7.0.4"
}
}
}
+50
View File
@@ -0,0 +1,50 @@
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
const ROOT = process.cwd();
const SRC_DIR = path.join(ROOT, "src");
const ALLOWED_FILE = path.normalize(path.join(SRC_DIR, "lib", "logger.ts"));
const FILE_EXTENSIONS = new Set([".ts", ".tsx"]);
const CONSOLE_PATTERN = /\bconsole\.(debug|info|warn|error|log)\s*\(/g;
const violations = [];
async function walk(directory) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
const resolved = path.join(directory, entry.name);
if (entry.isDirectory()) {
await walk(resolved);
continue;
}
if (!FILE_EXTENSIONS.has(path.extname(entry.name))) {
continue;
}
if (path.normalize(resolved) === ALLOWED_FILE) {
continue;
}
const content = await readFile(resolved, "utf8");
const lines = content.split(/\r?\n/);
lines.forEach((line, index) => {
if (CONSOLE_PATTERN.test(line)) {
violations.push(`${path.relative(ROOT, resolved)}:${index + 1}: ${line.trim()}`);
}
CONSOLE_PATTERN.lastIndex = 0;
});
}
}
await walk(SRC_DIR);
if (violations.length > 0) {
console.error("Unexpected console.* usage outside src/lib/logger.ts:");
for (const violation of violations) {
console.error(` ${violation}`);
}
process.exitCode = 1;
}
+13
View File
@@ -7108,6 +7108,16 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-serde"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1"
dependencies = [
"serde",
"tracing-core",
]
[[package]]
name = "tracing-subscriber"
version = "0.3.22"
@@ -7118,6 +7128,8 @@ dependencies = [
"nu-ansi-term",
"once_cell",
"regex-automata",
"serde",
"serde_json",
"sharded-slab",
"smallvec",
"thread_local",
@@ -7125,6 +7137,7 @@ dependencies = [
"tracing",
"tracing-core",
"tracing-log",
"tracing-serde",
]
[[package]]
+1 -1
View File
@@ -52,7 +52,7 @@ uuid = { version = "1", features = ["v4"] }
async-trait = "0.1"
thiserror = "2"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "local-time"] }
tracing-subscriber = { version = "0.3", features = ["env-filter", "json", "local-time"] }
time = { version = "0.3", features = ["formatting", "local-offset", "macros"] }
tracing-appender = "0.2"
nucleo-matcher = "0.3"
+19 -43
View File
@@ -1,50 +1,8 @@
use std::sync::Arc;
use tauri::Manager;
use tracing_subscriber::{fmt, layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
use crate::core::SessionManager;
pub fn init_tracing(log_dir: std::path::PathBuf) {
let _ = std::fs::create_dir_all(&log_dir);
let file_appender = tracing_appender::rolling::Builder::new()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix("dragonfly")
.filename_suffix("log")
.max_log_files(7)
.build(&log_dir)
.expect("failed to initialize rolling file appender");
let filter = EnvFilter::try_from_default_env()
.unwrap_or_else(|_| EnvFilter::new("dragonfly=info,user_action=debug,warn"));
let local_time = fmt::time::OffsetTime::local_rfc_3339().unwrap_or_else(|_| {
fmt::time::OffsetTime::new(
time::UtcOffset::UTC,
time::format_description::well_known::Rfc3339,
)
});
tracing_subscriber::registry()
.with(filter)
.with(
fmt::layer()
.with_writer(file_appender)
.with_ansi(false)
.with_target(true)
.with_timer(local_time.clone()),
)
.with(
fmt::layer()
.with_writer(std::io::stderr)
.compact()
.with_timer(local_time),
)
.init();
tracing::info!("Dragonfly starting");
}
pub fn setup(
app: &mut tauri::App,
session_manager: Arc<SessionManager>,
@@ -54,8 +12,26 @@ pub fn setup(
.home_dir()
.map_err(|e: tauri::Error| e.to_string())?;
let settings_load = crate::config::load_app_settings(app.handle());
let diagnostics = settings_load
.as_ref()
.map(|settings| settings.diagnostics.clone())
.unwrap_or_default();
let log_dir = app.path().app_log_dir().map_err(|e| e.to_string())?;
init_tracing(log_dir);
crate::observability::init_tracing(log_dir, &diagnostics);
if let Err(error) = settings_load {
crate::observability::log_event(crate::observability::StructuredLog {
level: crate::observability::StructuredLogLevel::Warn,
domain: "settings.persistence".to_string(),
event: "settings.load_failed".to_string(),
message: "Failed to load app settings before tracing initialization".to_string(),
ids: None,
data: None,
error: Some(serde_json::json!({ "message": error.to_string() })),
client_timestamp: None,
});
}
session_manager.set_app_handle(app.handle().clone());
+22 -37
View File
@@ -1,44 +1,29 @@
use serde::Deserialize;
use std::sync::Arc;
use crate::core::ssh::TunnelManager;
use crate::core::SessionManager;
use crate::error::AppResult;
use crate::observability::{self, FrontendLogEntry};
#[derive(Clone, Copy, Debug, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Debug,
Info,
Warn,
Error,
#[tauri::command]
pub fn append_frontend_logs(entries: Vec<FrontendLogEntry>) -> AppResult<()> {
for entry in entries {
observability::log_event(observability::frontend_log_to_structured(entry));
}
Ok(())
}
#[tauri::command]
pub fn write_log(level: LogLevel, message: String, context: Option<String>) -> AppResult<()> {
match (level, context.as_deref()) {
(LogLevel::Debug, Some(context)) => {
tracing::debug!(source = "frontend", context = %context, "{message}");
}
(LogLevel::Debug, None) => {
tracing::debug!(source = "frontend", "{message}");
}
(LogLevel::Info, Some(context)) => {
tracing::info!(source = "frontend", context = %context, "{message}");
}
(LogLevel::Info, None) => {
tracing::info!(source = "frontend", "{message}");
}
(LogLevel::Warn, Some(context)) => {
tracing::warn!(source = "frontend", context = %context, "{message}");
}
(LogLevel::Warn, None) => {
tracing::warn!(source = "frontend", "{message}");
}
(LogLevel::Error, Some(context)) => {
tracing::error!(source = "frontend", context = %context, "{message}");
}
(LogLevel::Error, None) => {
tracing::error!(source = "frontend", "{message}");
}
}
Ok(())
pub fn export_diagnostics(
app: tauri::AppHandle,
session_manager: tauri::State<'_, Arc<SessionManager>>,
tunnel_manager: tauri::State<'_, Arc<TunnelManager>>,
output_path: String,
) -> AppResult<()> {
observability::export_diagnostics(
&app,
session_manager.inner().as_ref(),
tunnel_manager.inner().as_ref(),
&output_path,
)
}
+79 -10
View File
@@ -2,6 +2,7 @@ use crate::config;
use crate::core::ssh::{self, PendingAuthManager};
use crate::core::{self, RecordingManager, SessionCommand, SessionInfo, SessionManager};
use crate::error::{AppError, AppResult};
use crate::observability::{self, StructuredLog, StructuredLogLevel};
use crate::utils::fuzzy::{fuzzy_search_items, FuzzyResult};
use std::sync::Arc;
use tauri::Manager;
@@ -169,6 +170,17 @@ pub async fn close_session(
) -> AppResult<()> {
let session_id_clone = session_id.clone();
observability::log_event(StructuredLog {
level: StructuredLogLevel::Info,
domain: "session.lifecycle".to_string(),
event: "session.close_requested".to_string(),
message: "Closing session".to_string(),
ids: Some(serde_json::json!({ "session_id": session_id.clone() })),
data: None,
error: None,
client_timestamp: None,
});
let res = match state.send_command(&session_id, SessionCommand::Close).await {
Err(AppError::SessionNotFound(_)) => Ok(()),
other => other,
@@ -180,16 +192,31 @@ pub async fn close_session(
let session_temp_dir = temp_dir.join("dragonfly").join(&session_id_clone);
if session_temp_dir.exists() {
if let Err(e) = tokio::fs::remove_dir_all(&session_temp_dir).await {
tracing::warn!(
"Failed to clean up temp directory {}: {}",
session_temp_dir.display(),
e
);
observability::log_event(StructuredLog {
level: StructuredLogLevel::Warn,
domain: "session.lifecycle".to_string(),
event: "session.temp_cleanup_failed".to_string(),
message: "Failed to clean up session temp directory".to_string(),
ids: Some(serde_json::json!({ "session_id": session_id_clone })),
data: Some(serde_json::json!({
"temp_dir": session_temp_dir,
})),
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
} else {
tracing::info!(
"Successfully cleaned up temp directory for session: {}",
session_id_clone
);
observability::log_event(StructuredLog {
level: StructuredLogLevel::Info,
domain: "session.lifecycle".to_string(),
event: "session.temp_cleanup_succeeded".to_string(),
message: "Cleaned up session temp directory".to_string(),
ids: Some(serde_json::json!({ "session_id": session_id_clone })),
data: Some(serde_json::json!({
"temp_dir": session_temp_dir,
})),
error: None,
client_timestamp: None,
});
}
}
}
@@ -300,8 +327,28 @@ pub async fn submit_otp_response(
responses: Vec<String>,
) -> AppResult<()> {
if state.respond(&request_id, Some(responses)).await {
observability::log_event(StructuredLog {
level: StructuredLogLevel::Info,
domain: "security.flow".to_string(),
event: "otp.response_received".to_string(),
message: "Received OTP response from frontend".to_string(),
ids: Some(serde_json::json!({ "request_id": request_id })),
data: None,
error: None,
client_timestamp: None,
});
Ok(())
} else {
observability::log_event(StructuredLog {
level: StructuredLogLevel::Warn,
domain: "security.flow".to_string(),
event: "otp.response_rejected".to_string(),
message: "Rejected OTP response for missing request".to_string(),
ids: Some(serde_json::json!({ "request_id": request_id.clone() })),
data: None,
error: None,
client_timestamp: None,
});
Err(AppError::Auth(format!(
"No pending OTP request with id '{}'",
request_id
@@ -314,6 +361,28 @@ pub async fn cancel_otp_request(
state: tauri::State<'_, Arc<PendingAuthManager>>,
request_id: String,
) -> AppResult<()> {
state.respond(&request_id, None).await;
let cancelled = state.respond(&request_id, None).await;
observability::log_event(StructuredLog {
level: if cancelled {
StructuredLogLevel::Info
} else {
StructuredLogLevel::Warn
},
domain: "security.flow".to_string(),
event: if cancelled {
"otp.request_cancelled".to_string()
} else {
"otp.request_cancel_missing".to_string()
},
message: if cancelled {
"Cancelled OTP request".to_string()
} else {
"OTP request was already missing when cancellation arrived".to_string()
},
ids: Some(serde_json::json!({ "request_id": request_id })),
data: None,
error: None,
client_timestamp: None,
});
Ok(())
}
+35 -2
View File
@@ -1,5 +1,6 @@
use crate::config;
use crate::error::AppResult;
use crate::observability::{self, StructuredLog, StructuredLogLevel};
use crate::utils::crypto;
#[tauri::command]
@@ -27,7 +28,22 @@ pub fn save_app_settings(
app: tauri::AppHandle,
mut settings: config::AppSettings,
) -> AppResult<()> {
let existing = config::load_app_settings(&app)?;
let existing = match config::load_app_settings(&app) {
Ok(existing) => existing,
Err(error) => {
observability::log_event(StructuredLog {
level: StructuredLogLevel::Error,
domain: "settings.persistence".to_string(),
event: "settings.load_failed".to_string(),
message: "Failed to load existing app settings before save".to_string(),
ids: None,
data: None,
error: Some(serde_json::json!({ "message": error.to_string() })),
client_timestamp: None,
});
return Err(error);
}
};
match settings.security.master_password.as_deref() {
Some("__SET__") => {
@@ -56,7 +72,24 @@ pub fn save_app_settings(
settings.security.master_password = Some(crypto::encrypt_settings_secret(plain)?);
}
}
config::save_app_settings(&app, &settings)
if let Err(error) = config::save_app_settings(&app, &settings) {
observability::log_event(StructuredLog {
level: StructuredLogLevel::Error,
domain: "settings.persistence".to_string(),
event: "settings.save_failed".to_string(),
message: "Failed to persist app settings".to_string(),
ids: None,
data: Some(serde_json::json!({
"diagnostics_level": settings.diagnostics.level.as_str(),
"diagnostics_retention_days": settings.diagnostics.retention_days,
})),
error: Some(serde_json::json!({ "message": error.to_string() })),
client_timestamp: None,
});
return Err(error);
}
Ok(())
}
#[tauri::command]
+3 -3
View File
@@ -35,9 +35,9 @@ pub use quick_command::{
#[allow(unused_imports)]
pub use settings::{
load_app_settings, save_app_settings, ActionLinksMatcherSettings, AppSettings,
AppearanceSettings, GeneralSettings, InteractionSettings, KeywordHighlightRule, ProxySettings,
SearchEngine, SearchSettings, SecuritySettings, TerminalSettings, TransferSettings,
TranslationSettings,
AppearanceSettings, DiagnosticsLogLevel, DiagnosticsSettings, GeneralSettings,
InteractionSettings, KeywordHighlightRule, ProxySettings, SearchEngine, SearchSettings,
SecuritySettings, TerminalSettings, TransferSettings, TranslationSettings,
};
#[allow(unused_imports)]
pub use tunnel::{load_tunnels, save_tunnels, TunnelConfig, TunnelsConfig};
@@ -0,0 +1,46 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum DiagnosticsLogLevel {
Warn,
Info,
Debug,
}
impl Default for DiagnosticsLogLevel {
fn default() -> Self {
Self::Info
}
}
impl DiagnosticsLogLevel {
pub const fn as_str(self) -> &'static str {
match self {
Self::Warn => "warn",
Self::Info => "info",
Self::Debug => "debug",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagnosticsSettings {
#[serde(default)]
pub level: DiagnosticsLogLevel,
#[serde(default = "default_retention_days")]
pub retention_days: u32,
}
const fn default_retention_days() -> u32 {
7
}
impl Default for DiagnosticsSettings {
fn default() -> Self {
Self {
level: DiagnosticsLogLevel::Info,
retention_days: default_retention_days(),
}
}
}
+4
View File
@@ -1,4 +1,5 @@
mod appearance;
mod diagnostics;
mod general;
mod interaction;
mod proxy;
@@ -9,6 +10,7 @@ mod transfer;
mod translation;
pub use appearance::AppearanceSettings;
pub use diagnostics::{DiagnosticsLogLevel, DiagnosticsSettings};
pub use general::GeneralSettings;
pub use interaction::InteractionSettings;
pub use proxy::ProxySettings;
@@ -45,6 +47,8 @@ pub struct AppSettings {
#[serde(default)]
pub transfer: TransferSettings,
#[serde(default)]
pub diagnostics: DiagnosticsSettings,
#[serde(default)]
pub ui: UiConfig,
}
+53 -9
View File
@@ -5,6 +5,7 @@ use super::session::{
};
use crate::core::SessionOutputCoalescer;
use crate::error::{AppError, AppResult};
use crate::observability::{log_event, log_rate_limited, StructuredLog, StructuredLogLevel};
use serialport::{DataBits, FlowControl, Parity, StopBits};
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
@@ -59,13 +60,24 @@ pub async fn create_serial_session(
app: AppHandle,
manager: Arc<SessionManager>,
config: SerialConfig,
_connection_id: Option<String>,
connection_id: Option<String>,
) -> AppResult<String> {
tracing::info!(
"Creating serial session on {} @ {} baud",
config.port_name,
config.baud_rate
);
log_event(StructuredLog {
level: StructuredLogLevel::Info,
domain: "session.lifecycle".to_string(),
event: "session.create_start".to_string(),
message: "Creating serial session".to_string(),
ids: connection_id
.as_ref()
.map(|value| serde_json::json!({ "connection_id": value })),
data: Some(serde_json::json!({
"session_type": "Serial",
"port_name": config.port_name.clone(),
"baud_rate": config.baud_rate,
})),
error: None,
client_timestamp: None,
});
let session_id = uuid::Uuid::new_v4().to_string();
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<SessionCommand>();
@@ -93,7 +105,7 @@ pub async fn create_serial_session(
let rt_handle = tokio::runtime::Handle::current();
std::thread::spawn(move || {
serial_session_thread(app, sid, mgr, cmd_rx, rt_handle, config);
serial_session_thread(app, sid, mgr, cmd_rx, rt_handle, config, connection_id);
});
Ok(session_id)
@@ -106,6 +118,7 @@ fn serial_session_thread(
mut cmd_rx: mpsc::UnboundedReceiver<SessionCommand>,
rt_handle: tokio::runtime::Handle,
config: SerialConfig,
connection_id: Option<String>,
) {
let port = match serialport::new(&config.port_name, config.baud_rate)
.data_bits(parse_data_bits(config.data_bits))
@@ -117,7 +130,23 @@ fn serial_session_thread(
{
Ok(p) => p,
Err(e) => {
tracing::error!("Failed to open serial port: {}", e);
log_event(StructuredLog {
level: StructuredLogLevel::Error,
domain: "session.lifecycle".to_string(),
event: "session.connection_failed".to_string(),
message: "Failed to open serial port".to_string(),
ids: Some(serde_json::json!({
"session_id": session_id.clone(),
"connection_id": connection_id.clone(),
})),
data: Some(serde_json::json!({
"session_type": "Serial",
"port_name": config.port_name.clone(),
"baud_rate": config.baud_rate,
})),
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
let _ = app.emit(
&format!("session-error-{}", session_id),
format!("Failed to open serial port: {}", e),
@@ -159,7 +188,22 @@ fn serial_session_thread(
continue;
}
Err(e) => {
tracing::warn!("Serial read error: {}", e);
log_rate_limited(StructuredLog {
level: StructuredLogLevel::Warn,
domain: "session.lifecycle".to_string(),
event: "serial.read_error".to_string(),
message: "Serial read error".to_string(),
ids: Some(serde_json::json!({
"session_id": sid_reader.clone(),
"connection_id": connection_id.clone(),
})),
data: Some(serde_json::json!({
"session_type": "Serial",
"port_name": config.port_name.clone(),
})),
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
break;
}
}
+228 -75
View File
@@ -1,8 +1,10 @@
use super::client::{SshAuth, SshConfig, SshHandler};
use crate::error::{AppError, AppResult};
use crate::observability::{self, StructuredLog, StructuredLogLevel};
use russh::client::{self, KeyboardInteractiveAuthResponse};
use russh::MethodKind;
use serde::Serialize;
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::{AppHandle, Emitter, Manager};
@@ -50,6 +52,46 @@ struct OtpRequestPayload {
otp_entry_id: Option<String>,
}
fn build_ids(connection_id: Option<&str>, request_id: Option<&str>) -> Option<Value> {
let mut ids = Map::new();
if let Some(connection_id) = connection_id {
ids.insert(
"connection_id".to_string(),
Value::String(connection_id.to_string()),
);
}
if let Some(request_id) = request_id {
ids.insert("request_id".to_string(), Value::String(request_id.to_string()));
}
if ids.is_empty() {
None
} else {
Some(Value::Object(ids))
}
}
fn log_structured(
level: StructuredLogLevel,
domain: &str,
event: &str,
message: &str,
connection_id: Option<&str>,
request_id: Option<&str>,
data: Option<Value>,
error: Option<Value>,
) {
observability::log_event(StructuredLog {
level,
domain: domain.to_string(),
event: event.to_string(),
message: message.to_string(),
ids: build_ids(connection_id, request_id),
data,
error,
client_timestamp: None,
});
}
pub(crate) fn load_saved_ssh_config(app: &AppHandle, connection_id: &str) -> AppResult<SshConfig> {
let conn = crate::config::load_connection_by_id(app, connection_id)?;
resolve_saved_ssh_config(app, &conn, Some(connection_id.to_string()), true)
@@ -216,12 +258,20 @@ pub(super) async fn authenticate_handle(
match &config.auth {
SshAuth::Password { password } => {
tracing::info!(
host = %config.host,
port = config.port,
user = %config.username,
auth_mode = "password",
"Starting SSH authentication"
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"auth.start",
"Starting SSH authentication",
config.connection_id.as_deref(),
None,
Some(json!({
"host": config.host,
"port": config.port,
"username": config.username,
"auth_mode": "password",
})),
None,
);
let authenticated = handle
@@ -233,6 +283,7 @@ pub(super) async fn authenticate_handle(
handle,
&authenticated,
&config.username,
config.connection_id.as_deref(),
&config.name,
app,
password_error,
@@ -253,14 +304,22 @@ pub(super) async fn authenticate_handle(
.flatten()
.flatten();
tracing::info!(
host = %config.host,
port = config.port,
user = %config.username,
auth_mode = "publickey",
key_algorithm = %key.algorithm(),
rsa_hash = ?hash_alg,
"Starting SSH authentication"
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"auth.start",
"Starting SSH authentication",
config.connection_id.as_deref(),
None,
Some(json!({
"host": config.host,
"port": config.port,
"username": config.username,
"auth_mode": "publickey",
"key_algorithm": key.algorithm().to_string(),
"rsa_hash": format!("{hash_alg:?}"),
})),
None,
);
let authenticated = handle
@@ -275,6 +334,7 @@ pub(super) async fn authenticate_handle(
handle,
&authenticated,
&config.username,
config.connection_id.as_deref(),
&config.name,
app,
key_error,
@@ -285,11 +345,19 @@ pub(super) async fn authenticate_handle(
}
}
tracing::info!(
host = %config.host,
port = config.port,
user = %config.username,
"SSH authentication succeeded"
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"auth.succeeded",
"SSH authentication succeeded",
config.connection_id.as_deref(),
None,
Some(json!({
"host": config.host,
"port": config.port,
"username": config.username,
})),
None,
);
Ok(())
@@ -341,6 +409,7 @@ fn resolve_otp_info(app: &AppHandle, connection_id: &str) -> Option<OtpAutoFillI
async fn finish_keyboard_interactive(
handle: &mut client::Handle<SshHandler>,
username: &str,
connection_id: Option<&str>,
connection_name: &str,
app: &AppHandle,
mode: KeyboardInteractiveMode<'_>,
@@ -351,11 +420,18 @@ async fn finish_keyboard_interactive(
.ok_or_else(|| AppError::Auth("PendingAuthManager not available".to_string()))?;
let pending_mgr = pending_mgr.inner().clone();
tracing::info!(
connection_name,
username,
mode = mode.label(),
"Starting keyboard-interactive authentication"
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"keyboard_interactive.start",
"Starting keyboard-interactive authentication",
connection_id,
None,
Some(json!({
"username": username,
"mode": mode.label(),
})),
None,
);
let mut step = handle
@@ -366,11 +442,18 @@ async fn finish_keyboard_interactive(
loop {
match step {
KeyboardInteractiveAuthResponse::Success => {
tracing::info!(
connection_name,
username,
mode = mode.label(),
"Keyboard-interactive authentication succeeded"
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"keyboard_interactive.succeeded",
"Keyboard-interactive authentication succeeded",
connection_id,
None,
Some(json!({
"username": username,
"mode": mode.label(),
})),
None,
);
return Ok(());
}
@@ -378,13 +461,20 @@ async fn finish_keyboard_interactive(
remaining_methods,
partial_success,
} => {
tracing::warn!(
connection_name,
username,
mode = mode.label(),
?remaining_methods,
partial_success,
"Keyboard-interactive authentication failed"
log_structured(
StructuredLogLevel::Warn,
"ssh.auth",
"keyboard_interactive.failed",
"Keyboard-interactive authentication failed",
connection_id,
None,
Some(json!({
"username": username,
"mode": mode.label(),
"remaining_methods": format!("{remaining_methods:?}"),
"partial_success": partial_success,
})),
None,
);
return Err(AppError::Auth(
"Keyboard-interactive authentication failed".to_string(),
@@ -396,13 +486,20 @@ async fn finish_keyboard_interactive(
prompts,
} => {
let hidden_prompts = prompts.iter().filter(|prompt| !prompt.echo).count();
tracing::debug!(
connection_name,
username,
mode = mode.label(),
prompt_count = prompts.len(),
hidden_prompts,
"Received keyboard-interactive prompts"
log_structured(
StructuredLogLevel::Debug,
"ssh.auth",
"keyboard_interactive.prompts_received",
"Received keyboard-interactive prompts",
connection_id,
None,
Some(json!({
"username": username,
"mode": mode.label(),
"prompt_count": prompts.len(),
"hidden_prompts": hidden_prompts,
})),
None,
);
let responses = if prompts.is_empty() {
@@ -411,18 +508,34 @@ async fn finish_keyboard_interactive(
.password()
.filter(|_| should_auto_fill_password_prompts(&prompts))
{
tracing::info!(
connection_name,
username,
"Auto-filling password for keyboard-interactive auth"
log_structured(
StructuredLogLevel::Info,
"security.flow",
"keyboard_interactive.password_autofill",
"Auto-filling password for keyboard-interactive auth",
connection_id,
None,
Some(json!({
"username": username,
"prompt_count": prompts.len(),
})),
None,
);
vec![password.to_string()]
} else if let Some(info) = otp_info.filter(|i| i.auto_fill) {
tracing::info!(
connection_name,
username,
otp_entry_id = %info.otp_id,
"Auto-filling OTP for keyboard-interactive auth"
log_structured(
StructuredLogLevel::Info,
"security.flow",
"otp.auto_fill",
"Auto-filling OTP for keyboard-interactive auth",
connection_id,
None,
Some(json!({
"username": username,
"otp_entry_id": info.otp_id,
"prompt_count": prompts.len(),
})),
None,
);
let result = crate::cmd::otp::generate_otp_for_entry(app, &info.otp_id)?;
prompts.iter().map(|_| result.code.clone()).collect()
@@ -442,12 +555,19 @@ async fn finish_keyboard_interactive(
.collect(),
otp_entry_id: otp_info.map(|i| i.otp_id.clone()),
};
tracing::info!(
connection_name,
username,
prompt_count = payload.prompts.len(),
otp_entry_id = payload.otp_entry_id.as_deref(),
"Forwarding keyboard-interactive prompts to frontend"
log_structured(
StructuredLogLevel::Info,
"security.flow",
"otp.requested",
"Forwarding keyboard-interactive prompts to frontend",
connection_id,
Some(&request_id),
Some(json!({
"username": username,
"prompt_count": payload.prompts.len(),
"otp_entry_id": payload.otp_entry_id,
})),
None,
);
let _ = app.emit("otp-request", &payload);
@@ -483,6 +603,7 @@ async fn try_keyboard_interactive_after_partial(
handle: &mut client::Handle<SshHandler>,
auth_result: &client::AuthResult,
username: &str,
connection_id: Option<&str>,
connection_name: &str,
app: &AppHandle,
fallback_error: &str,
@@ -501,15 +622,23 @@ async fn try_keyboard_interactive_after_partial(
keyboard_interactive_available && password_fallback.is_some();
if *partial_success && keyboard_interactive_available {
tracing::info!(
connection_name,
username,
?remaining_methods,
"Primary auth partial success, continuing with keyboard-interactive"
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"auth.partial_success",
"Primary auth partial success, continuing with keyboard-interactive",
connection_id,
None,
Some(json!({
"username": username,
"remaining_methods": format!("{remaining_methods:?}"),
})),
None,
);
finish_keyboard_interactive(
handle,
username,
connection_id,
connection_name,
app,
KeyboardInteractiveMode::AdditionalFactor,
@@ -517,24 +646,48 @@ async fn try_keyboard_interactive_after_partial(
)
.await
} else if can_retry_with_password_fallback {
tracing::info!(
connection_name,
username,
?remaining_methods,
"Password auth rejected, retrying with keyboard-interactive"
log_structured(
StructuredLogLevel::Info,
"ssh.auth",
"auth.password_fallback",
"Password auth rejected, retrying with keyboard-interactive",
connection_id,
None,
Some(json!({
"username": username,
"remaining_methods": format!("{remaining_methods:?}"),
})),
None,
);
let Some(mode) = password_fallback else {
return Err(AppError::Auth(fallback_error.to_string()));
};
finish_keyboard_interactive(handle, username, connection_name, app, mode, otp_info)
finish_keyboard_interactive(
handle,
username,
connection_id,
connection_name,
app,
mode,
otp_info,
)
.await
} else {
tracing::warn!(
connection_name,
username,
?remaining_methods,
partial_success = *partial_success,
"SSH authentication failed without usable keyboard-interactive fallback"
log_structured(
StructuredLogLevel::Warn,
"ssh.auth",
"auth.failed",
"SSH authentication failed without usable keyboard-interactive fallback",
connection_id,
None,
Some(json!({
"username": username,
"remaining_methods": format!("{remaining_methods:?}"),
"partial_success": partial_success,
})),
Some(json!({
"message": fallback_error,
})),
);
Err(AppError::Auth(fallback_error.to_string()))
}
+3 -8
View File
@@ -30,14 +30,9 @@ async fn create_authenticated_connection(
let jump_handler = SshHandler::new(app.clone(), jump_config.host.clone(), jump_config.port);
let mut jump_handle =
connect_with_proxy(jump_config, ssh_client_config.clone(), jump_handler).await?;
let jump_password_error = format!(
"Authentication failed for jump host '{}': invalid credentials",
jump_config.name
);
let jump_key_error = format!(
"Authentication failed for jump host '{}': key rejected",
jump_config.name
);
let jump_password_error =
"Authentication failed for jump host: invalid credentials".to_string();
let jump_key_error = "Authentication failed for jump host: key rejected".to_string();
authenticate_handle(
&mut jump_handle,
jump_config,
+33 -2
View File
@@ -6,6 +6,7 @@
use super::SshConnectionHandles;
use crate::core::SessionManager;
use crate::error::{AppError, AppResult};
use crate::observability::{log_event, StructuredLog, StructuredLogLevel};
use russh_sftp::client::SftpSession;
use russh_sftp::protocol::{FileAttributes, FileType, OpenFlags};
use serde::Serialize;
@@ -200,6 +201,10 @@ fn find_transfer(id: &str) -> Option<Arc<TransferController>> {
ACTIVE_TRANSFERS.lock().unwrap().get(id).cloned()
}
pub(crate) fn active_transfer_count() -> usize {
ACTIVE_TRANSFERS.lock().unwrap().len()
}
async fn wait_for_transfer_ready(controller: &Arc<TransferController>) -> AppResult<()> {
loop {
let notified = controller.notify.notified();
@@ -567,7 +572,20 @@ pub async fn download_remote_file(
let mut last_err = None;
for attempt in 0..=max_retries {
if attempt > 0 {
tracing::info!(attempt, "Retrying download of {}", remote_path);
log_event(StructuredLog {
level: StructuredLogLevel::Info,
domain: "transfer.lifecycle".to_string(),
event: "transfer.retry".to_string(),
message: "Retrying download".to_string(),
ids: Some(serde_json::json!({ "session_id": session_id })),
data: Some(serde_json::json!({
"direction": "download",
"attempt": attempt,
"remote_path": remote_path,
})),
error: None,
client_timestamp: None,
});
}
match download_remote_file_inner(
&app,
@@ -896,7 +914,20 @@ pub async fn upload_local_file(
let mut last_err = None;
for attempt in 0..=max_retries {
if attempt > 0 {
tracing::info!(attempt, "Retrying upload of {}", local_path);
log_event(StructuredLog {
level: StructuredLogLevel::Info,
domain: "transfer.lifecycle".to_string(),
event: "transfer.retry".to_string(),
message: "Retrying upload".to_string(),
ids: Some(serde_json::json!({ "session_id": session_id })),
data: Some(serde_json::json!({
"direction": "upload",
"attempt": attempt,
"local_path": local_path,
})),
error: None,
client_timestamp: None,
});
}
match upload_local_file_inner(
&app,
+121 -16
View File
@@ -3,6 +3,7 @@
use super::{create_ssh_handle, SshHandle, SshRawHandle};
use crate::config::{self, TunnelConfig};
use crate::error::{AppError, AppResult};
use crate::observability::{log_event, log_rate_limited, StructuredLog, StructuredLogLevel};
use std::collections::HashMap;
use std::sync::Arc;
use tauri::AppHandle;
@@ -30,6 +31,10 @@ impl TunnelManager {
self.active.lock().await.contains_key(tunnel_id)
}
pub async fn active_count(&self) -> usize {
self.active.lock().await.len()
}
pub async fn open(&self, tunnel: &TunnelConfig, app: &AppHandle) -> AppResult<()> {
{
let active = self.active.lock().await;
@@ -118,7 +123,25 @@ impl TunnelManager {
},
);
tracing::info!(tunnel_id = %tunnel.id, tunnel_type = %tunnel.tunnel_type, "Tunnel opened");
log_event(StructuredLog {
level: StructuredLogLevel::Info,
domain: "session.lifecycle".to_string(),
event: "tunnel.opened".to_string(),
message: "Tunnel opened".to_string(),
ids: Some(serde_json::json!({
"tunnel_id": tunnel.id.clone(),
"connection_id": tunnel.connection_id.clone(),
})),
data: Some(serde_json::json!({
"tunnel_type": tunnel.tunnel_type.clone(),
"listen_port": tunnel.listen_port,
"target_host": tunnel.target_host.clone(),
"target_port": tunnel.target_port,
"bind_localhost": tunnel.bind_localhost,
})),
error: None,
client_timestamp: None,
});
Ok(())
}
@@ -128,7 +151,16 @@ impl TunnelManager {
if let Some(tx) = handle.shutdown_tx.take() {
let _ = tx.send(());
}
tracing::info!(tunnel_id = %tunnel_id, "Tunnel closed");
log_event(StructuredLog {
level: StructuredLogLevel::Info,
domain: "session.lifecycle".to_string(),
event: "tunnel.closed".to_string(),
message: "Tunnel closed".to_string(),
ids: Some(serde_json::json!({ "tunnel_id": tunnel_id })),
data: None,
error: None,
client_timestamp: None,
});
}
}
@@ -158,7 +190,19 @@ impl TunnelManager {
).await {
Ok(ch) => ch,
Err(e) => {
tracing::warn!("direct-tcpip failed: {}", e);
log_rate_limited(StructuredLog {
level: StructuredLogLevel::Warn,
domain: "session.lifecycle".to_string(),
event: "tunnel.direct_tcpip_failed".to_string(),
message: "Tunnel direct-tcpip failed".to_string(),
ids: None,
data: Some(serde_json::json!({
"target_host": host,
"target_port": target_port,
})),
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
return;
}
}
@@ -168,7 +212,16 @@ impl TunnelManager {
});
}
Err(e) => {
tracing::warn!("TCP accept failed: {}", e);
log_rate_limited(StructuredLog {
level: StructuredLogLevel::Warn,
domain: "session.lifecycle".to_string(),
event: "tunnel.accept_failed".to_string(),
message: "Tunnel TCP accept failed".to_string(),
ids: None,
data: None,
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
}
}
}
@@ -187,14 +240,35 @@ impl TunnelManager {
{
let handle = ssh_handle.lock().await;
if let Err(e) = handle.tcpip_forward(&listen_addr, listen_port.into()).await {
tracing::warn!("tcpip_forward request failed: {}", e);
log_rate_limited(StructuredLog {
level: StructuredLogLevel::Warn,
domain: "session.lifecycle".to_string(),
event: "tunnel.forward_request_failed".to_string(),
message: "Remote tunnel forward request failed".to_string(),
ids: None,
data: Some(serde_json::json!({
"listen_addr": listen_addr.clone(),
"listen_port": listen_port,
})),
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
return;
}
}
tracing::info!(
listen = %format!("{}:{}", listen_addr, listen_port),
"Remote tunnel forwarding requested"
);
log_event(StructuredLog {
level: StructuredLogLevel::Info,
domain: "session.lifecycle".to_string(),
event: "tunnel.forward_requested".to_string(),
message: "Remote tunnel forwarding requested".to_string(),
ids: None,
data: Some(serde_json::json!({
"listen_addr": listen_addr.clone(),
"listen_port": listen_port,
})),
error: None,
client_timestamp: None,
});
let _ = shutdown_rx.await;
@@ -219,7 +293,16 @@ impl TunnelManager {
tokio::spawn(Self::handle_socks5_connection(stream, handle, peer_addr));
}
Err(e) => {
tracing::warn!("SOCKS5 accept failed: {}", e);
log_rate_limited(StructuredLog {
level: StructuredLogLevel::Warn,
domain: "session.lifecycle".to_string(),
event: "tunnel.socks_accept_failed".to_string(),
message: "SOCKS5 accept failed".to_string(),
ids: None,
data: None,
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
}
}
}
@@ -316,7 +399,19 @@ impl TunnelManager {
} {
Ok(ch) => ch,
Err(e) => {
tracing::warn!("SOCKS5 direct-tcpip failed: {}", e);
log_rate_limited(StructuredLog {
level: StructuredLogLevel::Warn,
domain: "session.lifecycle".to_string(),
event: "tunnel.socks_direct_tcpip_failed".to_string(),
message: "SOCKS5 direct-tcpip failed".to_string(),
ids: None,
data: Some(serde_json::json!({
"target_host": target_host,
"target_port": target_port,
})),
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
let _ = stream
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
.await;
@@ -342,11 +437,21 @@ impl TunnelManager {
for tunnel in &tunnels {
if tunnel.auto_open && tunnel.connection_id.as_deref() == Some(connection_id) {
if let Err(e) = self.open(tunnel, app).await {
tracing::warn!(
tunnel_id = %tunnel.id,
"Failed to auto-open tunnel: {}",
e
);
log_event(StructuredLog {
level: StructuredLogLevel::Warn,
domain: "session.lifecycle".to_string(),
event: "tunnel.auto_open_failed".to_string(),
message: "Failed to auto-open tunnel".to_string(),
ids: Some(serde_json::json!({
"tunnel_id": tunnel.id.clone(),
"connection_id": connection_id,
})),
data: Some(serde_json::json!({
"tunnel_type": tunnel.tunnel_type.clone(),
})),
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
}
}
}
+81 -6
View File
@@ -5,6 +5,7 @@ use super::session::{
};
use crate::core::SessionOutputCoalescer;
use crate::error::AppResult;
use crate::observability::{log_event, log_rate_limited, StructuredLog, StructuredLogLevel};
use std::sync::Arc;
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
@@ -113,7 +114,22 @@ pub async fn create_telnet_session(
connection_id: Option<String>,
name: String,
) -> AppResult<String> {
tracing::info!("Creating Telnet session to {}:{}", host, port);
log_event(StructuredLog {
level: StructuredLogLevel::Info,
domain: "session.lifecycle".to_string(),
event: "session.create_start".to_string(),
message: "Creating Telnet session".to_string(),
ids: connection_id
.as_ref()
.map(|value| serde_json::json!({ "connection_id": value })),
data: Some(serde_json::json!({
"session_type": "Telnet",
"host": host,
"port": port,
})),
error: None,
client_timestamp: None,
});
let session_id = uuid::Uuid::new_v4().to_string();
let (cmd_tx, cmd_rx) = mpsc::unbounded_channel::<SessionCommand>();
@@ -152,13 +168,29 @@ async fn telnet_session_task(
mut cmd_rx: mpsc::UnboundedReceiver<SessionCommand>,
host: String,
port: u16,
_connection_id: Option<String>,
connection_id: Option<String>,
) {
let addr = format!("{}:{}", host, port);
let stream = match TcpStream::connect(&addr).await {
Ok(s) => s,
Err(e) => {
tracing::error!("Telnet connection failed: {}", e);
log_event(StructuredLog {
level: StructuredLogLevel::Error,
domain: "session.lifecycle".to_string(),
event: "session.connection_failed".to_string(),
message: "Telnet connection failed".to_string(),
ids: Some(serde_json::json!({
"session_id": session_id.clone(),
"connection_id": connection_id.clone(),
})),
data: Some(serde_json::json!({
"session_type": "Telnet",
"host": host,
"port": port,
})),
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
let _ = app.emit(
&format!("session-error-{}", session_id),
format!("Connection failed: {}", e),
@@ -177,6 +209,7 @@ async fn telnet_session_task(
let app_reader = app.clone();
let sid_reader = session_id.clone();
let output_reader = output.clone();
let reader_connection_id = connection_id.clone();
// Shared channel for negotiation responses from the reader to the writer
let (negotiate_tx, mut negotiate_rx) = mpsc::unbounded_channel::<Vec<u8>>();
@@ -200,7 +233,21 @@ async fn telnet_session_task(
}
}
Err(e) => {
tracing::warn!("Telnet read error: {}", e);
log_rate_limited(StructuredLog {
level: StructuredLogLevel::Warn,
domain: "session.lifecycle".to_string(),
event: "telnet.read_error".to_string(),
message: "Telnet read error".to_string(),
ids: Some(serde_json::json!({
"session_id": sid_reader.clone(),
"connection_id": reader_connection_id.clone(),
})),
data: Some(serde_json::json!({
"session_type": "Telnet",
})),
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
break;
}
}
@@ -213,7 +260,21 @@ async fn telnet_session_task(
tokio::select! {
Some(neg_data) = negotiate_rx.recv() => {
if let Err(e) = writer.write_all(&neg_data).await {
tracing::warn!("Telnet negotiate write error: {}", e);
log_rate_limited(StructuredLog {
level: StructuredLogLevel::Warn,
domain: "session.lifecycle".to_string(),
event: "telnet.negotiate_write_error".to_string(),
message: "Telnet negotiate write error".to_string(),
ids: Some(serde_json::json!({
"session_id": session_id.clone(),
"connection_id": connection_id.clone(),
})),
data: Some(serde_json::json!({
"session_type": "Telnet",
})),
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
break;
}
}
@@ -224,7 +285,21 @@ async fn telnet_session_task(
}
Some(SessionCommand::Write(data)) => {
if let Err(e) = writer.write_all(&data).await {
tracing::warn!("Telnet write error: {}", e);
log_rate_limited(StructuredLog {
level: StructuredLogLevel::Warn,
domain: "session.lifecycle".to_string(),
event: "telnet.write_error".to_string(),
message: "Telnet write error".to_string(),
ids: Some(serde_json::json!({
"session_id": session_id.clone(),
"connection_id": connection_id.clone(),
})),
data: Some(serde_json::json!({
"session_type": "Telnet",
})),
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
break;
}
}
+44 -7
View File
@@ -7,6 +7,7 @@ use std::time::Duration;
use tauri::{AppHandle, Emitter};
use crate::error::{AppError, AppResult};
use crate::observability::{log_event, log_rate_limited, StructuredLog, StructuredLogLevel};
#[derive(Clone, Serialize)]
pub struct FileModifiedPayload {
@@ -70,7 +71,19 @@ pub async fn start_file_watch(
let local_path_clone = local_path.clone();
let remote_path_clone = remote_path.clone();
tracing::info!("Starting file watch for local path: {}", local_path);
log_event(StructuredLog {
level: StructuredLogLevel::Info,
domain: "watcher.sync".to_string(),
event: "watch.start".to_string(),
message: "Starting file watch".to_string(),
ids: Some(serde_json::json!({ "session_id": session_id })),
data: Some(serde_json::json!({
"local_path": local_path,
"remote_path": remote_path,
})),
error: None,
client_timestamp: None,
});
// Spawn a blocking thread to listen for notify events
std::thread::spawn(move || {
@@ -78,15 +91,15 @@ pub async fn start_file_watch(
for res in rx {
match res {
Ok(event) => {
tracing::info!("Notify event received: {:?}", event.kind);
tracing::debug!(kind = ?event.kind, "Notify event received");
// Most text editors do atomic saves (save to temp file, then rename/move)
// We need to catch Any/Data modify events inside the file OR rename events on the filepath
if let EventKind::Modify(_) = event.kind {
tracing::info!("Detected Modify event on: {:?}", event.paths);
tracing::debug!(paths = ?event.paths, "Detected modify event");
// Debounce: prevent emitting multiple times for a single save operation (common in editors)
if last_emit.elapsed() > Duration::from_millis(500) {
tracing::info!("Debounce passed, emitting 'file-modified' payload...");
tracing::debug!("Debounce passed, emitting file-modified payload");
last_emit = std::time::Instant::now();
let payload = FileModifiedPayload {
session_id: session_id_clone.clone(),
@@ -94,15 +107,39 @@ pub async fn start_file_watch(
remote_path: remote_path_clone.clone(),
};
if let Err(e) = app_clone.emit("file-modified", payload) {
tracing::error!("Failed to emit file-modified event: {:?}", e);
log_rate_limited(StructuredLog {
level: StructuredLogLevel::Error,
domain: "watcher.sync".to_string(),
event: "watch.emit_failed".to_string(),
message: "Failed to emit file-modified event".to_string(),
ids: Some(serde_json::json!({ "session_id": session_id_clone.clone() })),
data: Some(serde_json::json!({
"local_path": local_path_clone.clone(),
"remote_path": remote_path_clone.clone(),
})),
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
}
} else {
tracing::info!("Event debounced (too soon since last emit).");
tracing::debug!("Watcher event debounced");
}
}
}
Err(e) => {
tracing::error!("watch error: {:?}", e);
log_rate_limited(StructuredLog {
level: StructuredLogLevel::Error,
domain: "watcher.sync".to_string(),
event: "watch.backend_error".to_string(),
message: "File watcher backend error".to_string(),
ids: Some(serde_json::json!({ "session_id": session_id_clone.clone() })),
data: Some(serde_json::json!({
"local_path": local_path_clone.clone(),
"remote_path": remote_path_clone.clone(),
})),
error: Some(serde_json::json!({ "message": e.to_string() })),
client_timestamp: None,
});
break;
}
}
+3 -1
View File
@@ -5,6 +5,7 @@ mod cmd;
mod config;
mod core;
mod error;
mod observability;
mod utils;
use std::sync::Arc;
@@ -32,7 +33,8 @@ pub fn run() {
.on_window_event(app::on_window_event)
.invoke_handler(tauri::generate_handler![
cmd::clipboard::read_clipboard_text,
cmd::log::write_log,
cmd::log::append_frontend_logs,
cmd::log::export_diagnostics,
cmd::settings::get_system_fonts,
cmd::session::create_ssh_session,
cmd::session::create_local_session,
+849
View File
@@ -0,0 +1,849 @@
use std::collections::HashMap;
use std::fmt;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::{Duration, Instant, SystemTime};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use tauri::{AppHandle, Manager};
use tracing::{Event, Level, Subscriber};
use tracing_subscriber::fmt::format::Writer;
use tracing_subscriber::fmt::{FmtContext, FormatEvent, FormatFields};
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::{fmt as subscriber_fmt, util::SubscriberInitExt, EnvFilter};
use zip::write::SimpleFileOptions;
use crate::config::{self, DiagnosticsLogLevel, DiagnosticsSettings};
use crate::core::ssh::sftp;
use crate::core::{SessionInfo, SessionManager, SessionType};
use crate::error::{AppError, AppResult};
pub const LOG_FILE_PREFIX: &str = "dragonfly-diagnostics";
pub const LOG_FILE_SUFFIX: &str = "jsonl";
const DEFAULT_RETENTION_DAYS: u32 = 7;
const RATE_LIMIT_WINDOW: Duration = Duration::from_secs(30);
lazy_static::lazy_static! {
static ref RATE_LIMITS: Mutex<HashMap<String, RateLimitState>> = Mutex::new(HashMap::new());
}
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum StructuredLogLevel {
Debug,
Info,
Warn,
Error,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct FrontendLogEntry {
#[serde(default)]
pub timestamp: Option<String>,
pub level: StructuredLogLevel,
pub domain: String,
pub event: String,
pub message: String,
#[serde(default)]
pub ids: Option<Value>,
#[serde(default)]
pub data: Option<Value>,
#[serde(default)]
pub error: Option<Value>,
}
#[derive(Clone, Debug)]
pub struct StructuredLog {
pub level: StructuredLogLevel,
pub domain: String,
pub event: String,
pub message: String,
pub ids: Option<Value>,
pub data: Option<Value>,
pub error: Option<Value>,
pub client_timestamp: Option<String>,
}
#[derive(Clone, Debug)]
struct RateLimitState {
last_emitted_at: Instant,
suppressed_count: u32,
}
#[derive(Clone, Default)]
struct StructuredJsonFormatter;
#[derive(Default)]
struct JsonFieldVisitor {
fields: Map<String, Value>,
}
pub fn init_tracing(log_dir: PathBuf, settings: &DiagnosticsSettings) {
let retention_days = normalize_retention_days(settings.retention_days);
let _ = std::fs::create_dir_all(&log_dir);
let _ = cleanup_old_logs(&log_dir, retention_days);
let file_appender = tracing_appender::rolling::Builder::new()
.rotation(tracing_appender::rolling::Rotation::DAILY)
.filename_prefix(LOG_FILE_PREFIX)
.filename_suffix(LOG_FILE_SUFFIX)
.max_log_files(retention_days as usize)
.build(&log_dir)
.expect("failed to initialize rolling file appender");
let filter =
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_filter(settings.level)));
let local_time = subscriber_fmt::time::OffsetTime::local_rfc_3339().unwrap_or_else(|_| {
subscriber_fmt::time::OffsetTime::new(
time::UtcOffset::UTC,
time::format_description::well_known::Rfc3339,
)
});
let file_layer = subscriber_fmt::layer()
.event_format(StructuredJsonFormatter)
.with_writer(file_appender)
.with_ansi(false);
let subscriber = tracing_subscriber::registry().with(filter).with(file_layer);
if cfg!(debug_assertions) {
subscriber
.with(
subscriber_fmt::layer()
.with_writer(std::io::stderr)
.compact()
.with_timer(local_time),
)
.init();
} else {
subscriber.init();
}
log_event(StructuredLog {
level: StructuredLogLevel::Info,
domain: "app.lifecycle".to_string(),
event: "app.start".to_string(),
message: "Dragonfly starting".to_string(),
ids: None,
data: Some(json!({
"log_level": settings.level.as_str(),
"retention_days": retention_days,
})),
error: None,
client_timestamp: None,
});
}
pub fn log_event(log: StructuredLog) {
let ids = serialize_json_value(log.ids);
let data = serialize_json_value(log.data);
let error = serialize_json_value(log.error);
let client_timestamp = log.client_timestamp.unwrap_or_default();
match log.level {
StructuredLogLevel::Debug => tracing::event!(
Level::DEBUG,
domain = log.domain.as_str(),
event = log.event.as_str(),
message = log.message.as_str(),
ids = ids.as_deref().unwrap_or(""),
data = data.as_deref().unwrap_or(""),
error = error.as_deref().unwrap_or(""),
client_timestamp = client_timestamp.as_str(),
),
StructuredLogLevel::Info => tracing::event!(
Level::INFO,
domain = log.domain.as_str(),
event = log.event.as_str(),
message = log.message.as_str(),
ids = ids.as_deref().unwrap_or(""),
data = data.as_deref().unwrap_or(""),
error = error.as_deref().unwrap_or(""),
client_timestamp = client_timestamp.as_str(),
),
StructuredLogLevel::Warn => tracing::event!(
Level::WARN,
domain = log.domain.as_str(),
event = log.event.as_str(),
message = log.message.as_str(),
ids = ids.as_deref().unwrap_or(""),
data = data.as_deref().unwrap_or(""),
error = error.as_deref().unwrap_or(""),
client_timestamp = client_timestamp.as_str(),
),
StructuredLogLevel::Error => tracing::event!(
Level::ERROR,
domain = log.domain.as_str(),
event = log.event.as_str(),
message = log.message.as_str(),
ids = ids.as_deref().unwrap_or(""),
data = data.as_deref().unwrap_or(""),
error = error.as_deref().unwrap_or(""),
client_timestamp = client_timestamp.as_str(),
),
}
}
pub fn log_rate_limited(log: StructuredLog) {
let key = rate_limit_key(&log);
let now = Instant::now();
let mut should_log = true;
let mut summary_count = None;
{
let mut states = RATE_LIMITS.lock().unwrap();
match states.get_mut(&key) {
Some(state) if now.duration_since(state.last_emitted_at) < RATE_LIMIT_WINDOW => {
state.suppressed_count += 1;
should_log = false;
}
Some(state) => {
if state.suppressed_count > 0 {
summary_count = Some(state.suppressed_count);
}
state.last_emitted_at = now;
state.suppressed_count = 0;
}
None => {
states.insert(
key,
RateLimitState {
last_emitted_at: now,
suppressed_count: 0,
},
);
}
}
}
if let Some(suppressed_count) = summary_count {
let mut data = log.data.clone().unwrap_or_else(|| Value::Object(Map::new()));
if let Value::Object(ref mut map) = data {
map.insert(
"suppressed_count".to_string(),
Value::Number(serde_json::Number::from(suppressed_count)),
);
map.insert(
"suppressed_window_seconds".to_string(),
Value::Number(serde_json::Number::from(RATE_LIMIT_WINDOW.as_secs())),
);
}
log_event(StructuredLog {
level: log.level,
domain: log.domain.clone(),
event: format!("{}.suppressed", log.event),
message: "Suppressed repeated log entries".to_string(),
ids: log.ids.clone(),
data: Some(data),
error: None,
client_timestamp: None,
});
}
if should_log {
log_event(log);
}
}
pub fn export_diagnostics(
app: &AppHandle,
session_manager: &SessionManager,
tunnel_manager: &crate::core::ssh::TunnelManager,
output_path: &str,
) -> AppResult<()> {
let settings = config::load_app_settings(app).unwrap_or_default();
let log_dir = app.path().app_log_dir().map_err(|e| AppError::Config(e.to_string()))?;
let log_files = collect_log_files(
&log_dir,
normalize_retention_days(settings.diagnostics.retention_days),
)?;
if let Some(parent) = Path::new(output_path).parent() {
let _ = std::fs::create_dir_all(parent);
}
let file = std::fs::File::create(output_path)?;
let mut zip = zip::ZipWriter::new(file);
let options = SimpleFileOptions::default().compression_method(zip::CompressionMethod::Deflated);
for log_file in log_files {
let contents = std::fs::read(&log_file)?;
let file_name = log_file
.file_name()
.and_then(|value| value.to_str())
.ok_or_else(|| AppError::Config("invalid log file name".to_string()))?;
zip.start_file(file_name, options)
.map_err(|e| AppError::Config(format!("zip write: {e}")))?;
use std::io::Write;
zip.write_all(&contents)?;
}
let manifest = build_manifest(app, &settings);
zip.start_file("manifest.json", options)
.map_err(|e| AppError::Config(format!("zip write: {e}")))?;
use std::io::Write;
zip.write_all(
serde_json::to_string_pretty(&manifest)
.map_err(|e| AppError::Config(format!("serialize manifest: {e}")))?
.as_bytes(),
)?;
let runtime_snapshot = build_runtime_snapshot(app, &settings, session_manager, tunnel_manager);
zip.start_file("runtime_snapshot.json", options)
.map_err(|e| AppError::Config(format!("zip write: {e}")))?;
zip.write_all(
serde_json::to_string_pretty(&runtime_snapshot)
.map_err(|e| AppError::Config(format!("serialize runtime snapshot: {e}")))?
.as_bytes(),
)?;
zip.finish()
.map_err(|e| AppError::Config(format!("zip finalize: {e}")))?;
Ok(())
}
pub fn frontend_log_to_structured(entry: FrontendLogEntry) -> StructuredLog {
StructuredLog {
level: entry.level,
domain: entry.domain,
event: entry.event,
message: entry.message,
ids: entry.ids,
data: entry.data,
error: entry.error,
client_timestamp: entry.timestamp,
}
}
impl<S, N> FormatEvent<S, N> for StructuredJsonFormatter
where
S: Subscriber + for<'lookup> LookupSpan<'lookup>,
N: for<'writer> FormatFields<'writer> + 'static,
{
fn format_event(
&self,
_ctx: &FmtContext<'_, S, N>,
mut writer: Writer<'_>,
event: &Event<'_>,
) -> fmt::Result {
let metadata = event.metadata();
let mut visitor = JsonFieldVisitor::default();
event.record(&mut visitor);
let entry = build_log_entry_json(
&metadata.level().to_string().to_ascii_lowercase(),
metadata.target(),
visitor.fields,
format_timestamp(),
);
let serialized = serde_json::to_string(&entry).map_err(|_| fmt::Error)?;
writer.write_str(&serialized)?;
writer.write_str("\n")
}
}
impl tracing::field::Visit for JsonFieldVisitor {
fn record_i64(&mut self, field: &tracing::field::Field, value: i64) {
self.fields.insert(
field.name().to_string(),
Value::Number(serde_json::Number::from(value)),
);
}
fn record_u64(&mut self, field: &tracing::field::Field, value: u64) {
self.fields.insert(
field.name().to_string(),
Value::Number(serde_json::Number::from(value)),
);
}
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
self.fields
.insert(field.name().to_string(), Value::Bool(value));
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.fields
.insert(field.name().to_string(), Value::String(value.to_string()));
}
fn record_error(
&mut self,
field: &tracing::field::Field,
value: &(dyn std::error::Error + 'static),
) {
self.fields.insert(
field.name().to_string(),
Value::String(value.to_string()),
);
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn fmt::Debug) {
self.fields.insert(
field.name().to_string(),
Value::String(format!("{value:?}")),
);
}
fn record_f64(&mut self, field: &tracing::field::Field, value: f64) {
if let Some(number) = serde_json::Number::from_f64(value) {
self.fields
.insert(field.name().to_string(), Value::Number(number));
}
}
}
fn build_log_entry_json(
level: &str,
target: &str,
mut fields: Map<String, Value>,
timestamp: String,
) -> Value {
let domain = take_string_field(&mut fields, "domain").unwrap_or_else(|| target.to_string());
let event = take_string_field(&mut fields, "event").unwrap_or_else(|| "unclassified".to_string());
let message = take_string_field(&mut fields, "message").unwrap_or_else(|| target.to_string());
let client_timestamp = take_string_field(&mut fields, "client_timestamp");
let ids = parse_reserved_json_field(fields.remove("ids"));
let error = parse_reserved_json_field(fields.remove("error"));
let mut data = match parse_reserved_json_field(fields.remove("data")) {
Some(Value::Object(map)) => map,
Some(other) => {
let mut map = Map::new();
map.insert("value".to_string(), other);
map
}
None => Map::new(),
};
if let Some(client_timestamp) = client_timestamp {
data.insert(
"client_timestamp".to_string(),
Value::String(client_timestamp),
);
}
for (key, value) in fields {
if matches!(value, Value::String(ref text) if text.is_empty()) {
continue;
}
data.insert(key, value);
}
let mut entry = Map::new();
entry.insert("timestamp".to_string(), Value::String(timestamp));
entry.insert("level".to_string(), Value::String(level.to_string()));
entry.insert("domain".to_string(), Value::String(domain));
entry.insert("event".to_string(), Value::String(event));
entry.insert(
"message".to_string(),
sanitize_value(Some("message"), Value::String(message)),
);
if let Some(ids) = ids {
entry.insert("ids".to_string(), sanitize_value(Some("ids"), ids));
}
if !data.is_empty() {
entry.insert(
"data".to_string(),
sanitize_value(Some("data"), Value::Object(data)),
);
}
if let Some(error) = error {
entry.insert("error".to_string(), sanitize_value(Some("error"), error));
}
Value::Object(entry)
}
fn build_manifest(app: &AppHandle, settings: &config::AppSettings) -> Value {
json!({
"app_version": app.package_info().version.to_string(),
"platform": std::env::consts::OS,
"arch": std::env::consts::ARCH,
"language": settings.ui.language.clone().unwrap_or_else(|| "en".to_string()),
"log_level": settings.diagnostics.level.as_str(),
"retention_days": normalize_retention_days(settings.diagnostics.retention_days),
"exported_at": format_timestamp(),
})
}
fn build_runtime_snapshot(
app: &AppHandle,
settings: &config::AppSettings,
session_manager: &SessionManager,
tunnel_manager: &crate::core::ssh::TunnelManager,
) -> Value {
let sessions = tauri::async_runtime::block_on(session_manager.list_sessions());
let tunnel_count = tauri::async_runtime::block_on(tunnel_manager.active_count());
let saved_tunnels = config::load_tunnels(app).map(|items| items.len()).unwrap_or(0);
let session_summary = summarize_sessions(&sessions);
json!({
"diagnostics": {
"level": settings.diagnostics.level.as_str(),
"retention_days": normalize_retention_days(settings.diagnostics.retention_days),
},
"ui": {
"language": settings.ui.language.clone().unwrap_or_else(|| "en".to_string()),
"active_left_panel": settings.ui.active_left_panel,
"active_right_panel": settings.ui.active_right_panel,
"show_quick_cmd_bar": settings.ui.show_quick_cmd_bar,
"show_serial_send_panel": settings.ui.show_serial_send_panel,
"show_remote_stats": settings.ui.show_remote_stats,
"open_tab_count": settings.ui.open_tabs.len(),
},
"session_summary": session_summary,
"transfer_summary": {
"active_count": sftp::active_transfer_count(),
},
"tunnel_summary": {
"active_count": tunnel_count,
"saved_count": saved_tunnels,
},
})
}
fn summarize_sessions(sessions: &[SessionInfo]) -> Value {
let mut summary = json!({
"active_total": sessions.len(),
"ssh": 0,
"local": 0,
"telnet": 0,
"serial": 0,
"cwd_tracking_enabled": 0,
});
if let Value::Object(ref mut map) = summary {
for session in sessions {
let key = match session.session_type {
SessionType::SSH => "ssh",
SessionType::Local => "local",
SessionType::Telnet => "telnet",
SessionType::Serial => "serial",
};
increment_counter(map, key);
if session.injection_active {
increment_counter(map, "cwd_tracking_enabled");
}
}
}
summary
}
fn increment_counter(map: &mut Map<String, Value>, key: &str) {
let next = map
.get(key)
.and_then(Value::as_u64)
.unwrap_or(0)
.saturating_add(1);
map.insert(key.to_string(), Value::Number(serde_json::Number::from(next)));
}
fn collect_log_files(log_dir: &Path, retention_days: u32) -> AppResult<Vec<PathBuf>> {
let min_modified = threshold_system_time(retention_days);
let mut files = Vec::new();
for entry in std::fs::read_dir(log_dir)? {
let entry = entry?;
let path = entry.path();
if !is_log_file(&path) {
continue;
}
let modified = entry
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
if modified < min_modified {
continue;
}
files.push(path);
}
files.sort();
Ok(files)
}
fn cleanup_old_logs(log_dir: &Path, retention_days: u32) -> AppResult<()> {
let min_modified = threshold_system_time(retention_days);
for entry in std::fs::read_dir(log_dir)? {
let entry = entry?;
let path = entry.path();
if !is_log_file(&path) {
continue;
}
let modified = entry
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(SystemTime::UNIX_EPOCH);
if modified < min_modified {
let _ = std::fs::remove_file(path);
}
}
Ok(())
}
fn threshold_system_time(retention_days: u32) -> SystemTime {
SystemTime::now()
.checked_sub(Duration::from_secs(u64::from(retention_days) * 24 * 60 * 60))
.unwrap_or(SystemTime::UNIX_EPOCH)
}
fn is_log_file(path: &Path) -> bool {
path.file_name()
.and_then(|value| value.to_str())
.is_some_and(|value| value.starts_with(LOG_FILE_PREFIX) && value.ends_with(LOG_FILE_SUFFIX))
}
fn serialize_json_value(value: Option<Value>) -> Option<String> {
value.and_then(|item| serde_json::to_string(&item).ok())
}
fn parse_reserved_json_field(value: Option<Value>) -> Option<Value> {
match value {
Some(Value::String(text)) if text.is_empty() => None,
Some(Value::String(text)) => serde_json::from_str(&text)
.ok()
.or_else(|| Some(Value::String(text))),
Some(other) => Some(other),
None => None,
}
}
fn take_string_field(fields: &mut Map<String, Value>, key: &str) -> Option<String> {
match fields.remove(key) {
Some(Value::String(value)) if !value.is_empty() => Some(value),
Some(other) => Some(other.to_string()),
None => None,
}
}
fn rate_limit_key(log: &StructuredLog) -> String {
let ids = log
.ids
.as_ref()
.and_then(|value| serde_json::to_string(value).ok())
.unwrap_or_default();
let error = log
.error
.as_ref()
.and_then(normalized_error_key)
.unwrap_or_default();
format!("{}|{}|{}", log.event, ids, error)
}
fn normalized_error_key(value: &Value) -> Option<String> {
match value {
Value::Null => None,
Value::String(text) => Some(text.clone()),
other => serde_json::to_string(other).ok(),
}
}
fn sanitize_value(key: Option<&str>, value: Value) -> Value {
match value {
Value::Object(map) => Value::Object(
map.into_iter()
.map(|(child_key, child_value)| {
let sanitized = sanitize_value(Some(&child_key), child_value);
(child_key, sanitized)
})
.collect(),
),
Value::Array(items) => Value::Array(
items
.into_iter()
.map(|item| sanitize_value(key, item))
.collect(),
),
Value::String(text) => sanitize_string(key, text),
other => other,
}
}
fn sanitize_string(key: Option<&str>, value: String) -> Value {
let Some(key) = key else {
return Value::String(value);
};
if is_redacted_key(key) {
return Value::String("[REDACTED]".to_string());
}
if is_host_key(key) {
return json!({
"type": "host",
"hash": stable_hash("host", &value),
});
}
if is_username_key(key) {
return json!({
"type": "username",
"hash": stable_hash("username", &value),
});
}
if is_path_key(key) {
let extension = Path::new(&value)
.extension()
.and_then(|item| item.to_str())
.map(ToString::to_string);
let mut map = Map::new();
map.insert("type".to_string(), Value::String("path".to_string()));
map.insert(
"hash".to_string(),
Value::String(stable_hash("path", &value)),
);
if let Some(extension) = extension {
map.insert("extension".to_string(), Value::String(extension));
}
return Value::Object(map);
}
Value::String(value)
}
fn is_redacted_key(key: &str) -> bool {
let key = normalize_key(key);
matches!(
key.as_str(),
"password"
| "secret"
| "token"
| "otp"
| "command"
| "content"
| "clipboard"
| "passphrase"
| "master_password"
) || key.ends_with("_password")
|| key.ends_with("_secret")
|| key.ends_with("_token")
|| key.ends_with("_otp")
|| key.ends_with("_command")
|| key.ends_with("_content")
|| key.ends_with("_clipboard")
|| key.ends_with("_passphrase")
|| key.contains("private_key")
|| key.contains("public_key")
|| key.contains("key_data")
|| key.contains("secret_key")
|| (key.ends_with("_key") && !key.ends_with("_id"))
}
fn is_host_key(key: &str) -> bool {
let key = normalize_key(key);
key == "host" || key.ends_with("_host")
}
fn is_username_key(key: &str) -> bool {
let key = normalize_key(key);
key == "username" || key == "user" || key.ends_with("_username")
}
fn is_path_key(key: &str) -> bool {
let key = normalize_key(key);
key == "path" || key == "cwd" || key.ends_with("_path") || key.ends_with("_dir")
}
fn normalize_key(key: &str) -> String {
key.chars()
.map(|ch| {
if ch.is_ascii_alphanumeric() {
ch.to_ascii_lowercase()
} else {
'_'
}
})
.collect()
}
fn stable_hash(kind: &str, value: &str) -> String {
use sha2::Digest as _;
let mut hasher = sha2::Sha256::new();
hasher.update(kind.as_bytes());
hasher.update(b":");
hasher.update(value.as_bytes());
let digest = hasher.finalize();
hex::encode(&digest[..8])
}
fn default_filter(level: DiagnosticsLogLevel) -> String {
let app_level = match level {
DiagnosticsLogLevel::Warn => "warn",
DiagnosticsLogLevel::Info => "info",
DiagnosticsLogLevel::Debug => "debug",
};
format!("dragonfly={app_level},dragonfly_lib={app_level},warn")
}
fn normalize_retention_days(retention_days: u32) -> u32 {
match retention_days {
0 => DEFAULT_RETENTION_DAYS,
value => value.min(30),
}
}
fn format_timestamp() -> String {
let now = time::OffsetDateTime::now_local()
.unwrap_or_else(|_| time::OffsetDateTime::now_utc());
now.format(&time::format_description::well_known::Rfc3339)
.unwrap_or_else(|_| now.unix_timestamp().to_string())
}
#[cfg(test)]
mod tests {
use super::{build_log_entry_json, sanitize_value, Value};
use serde_json::{json, Map};
#[test]
fn sanitize_value_redacts_secret_fields() {
let value = json!({
"password": "hunter2",
"remote_path": "/var/log/app.log",
"host": "example.com",
"username": "root",
});
let sanitized = sanitize_value(Some("data"), value);
assert_eq!(sanitized["password"], "[REDACTED]");
assert_eq!(sanitized["remote_path"]["type"], "path");
assert_eq!(sanitized["host"]["type"], "host");
assert_eq!(sanitized["username"]["type"], "username");
}
#[test]
fn build_log_entry_merges_reserved_and_extra_fields() {
let mut fields = Map::new();
fields.insert("domain".to_string(), Value::String("ui.error".to_string()));
fields.insert("event".to_string(), Value::String("dialog.open_failed".to_string()));
fields.insert("message".to_string(), Value::String("Open failed".to_string()));
fields.insert(
"ids".to_string(),
Value::String("{\"session_id\":\"abc\"}".to_string()),
);
fields.insert(
"data".to_string(),
Value::String("{\"action\":\"open\"}".to_string()),
);
fields.insert("attempt".to_string(), Value::Number(1u64.into()));
let entry = build_log_entry_json(
"error",
"dragonfly_lib::ui",
fields,
"2026-01-01T00:00:00Z".to_string(),
);
assert_eq!(entry["domain"], "ui.error");
assert_eq!(entry["event"], "dialog.open_failed");
assert_eq!(entry["ids"]["session_id"], "abc");
assert_eq!(entry["data"]["action"], "open");
assert_eq!(entry["data"]["attempt"], 1);
}
}
+85 -15
View File
@@ -226,9 +226,7 @@ function App() {
setHelpDotVisible(true);
}
})
.catch((err) => {
console.warn("Update check failed:", err);
});
.catch(() => {});
}, 3000);
return () => clearTimeout(timer);
}, []);
@@ -615,7 +613,13 @@ function App() {
await invoke("close_session", { sessionId: pane.sessionId });
return true;
} catch (error) {
logger.error("Failed to close session", error);
logger.error({
domain: "session.lifecycle",
event: "session.close_failed",
message: "Failed to close session",
ids: { session_id: pane.sessionId },
error,
});
return false;
}
},
@@ -638,7 +642,12 @@ function App() {
await persistTabsNow();
return true;
} catch (error) {
logger.error("Failed to persist workspace tabs", error);
logger.error({
domain: "settings.persistence",
event: "workspace_tabs.persist_failed",
message: "Failed to persist workspace tabs",
error,
});
toast.error(message);
return false;
}
@@ -710,7 +719,15 @@ function App() {
.then((sessionId) => {
addTab(sessionId, t("menu.newLocalTerminal"), "Local");
})
.catch((e) => logger.error("Failed to create local session", e));
.catch((e) =>
logger.error({
domain: "session.lifecycle",
event: "session.create_failed",
message: "Failed to create local session",
data: { session_type: "Local" },
error: e,
}),
);
}, [addTab, t]);
const handleCloseActiveTab = useCallback(() => {
@@ -841,13 +858,24 @@ function App() {
updateTabSession(tabId, sessionId);
} catch (error) {
const errorMessage = getErrorMessage(error);
logger.error("Failed to duplicate session", error);
logger.error({
domain: "session.lifecycle",
event: "session.duplicate_failed",
message: "Failed to duplicate session",
ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined,
error,
});
markTabConnectionFailed(tabId, errorMessage);
maybePromptConnectionEdit(pane.connectionId, errorMessage, { sourceTabId: tabId });
toast.error(t("tabCtx.duplicateFailed"));
}
} catch (error) {
logger.error("Failed to create duplicated tab", error);
logger.error({
domain: "ui.error",
event: "tab.duplicate_failed",
message: "Failed to create duplicated tab",
error,
});
toast.error(t("tabCtx.duplicateFailed"));
}
},
@@ -879,7 +907,13 @@ function App() {
toast.success(t("tabCtx.reconnectSuccess"));
} catch (error) {
const errorMessage = getErrorMessage(error);
logger.error("Failed to reconnect session", error);
logger.error({
domain: "session.lifecycle",
event: "session.reconnect_failed",
message: "Failed to reconnect session",
ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined,
error,
});
maybePromptConnectionEdit(pane.connectionId, errorMessage, {
sourceTabId: tab.id,
sourcePaneId: pane.id,
@@ -915,7 +949,13 @@ function App() {
toast.success(t("tabCtx.reconnectSuccess"));
} catch (error) {
const errorMessage = getErrorMessage(error);
logger.error("Failed to reconnect session from active sessions panel", error);
logger.error({
domain: "session.lifecycle",
event: "session.reconnect_failed",
message: "Failed to reconnect session from active sessions panel",
ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined,
error,
});
maybePromptConnectionEdit(pane.connectionId, errorMessage, {
sourceTabId: tab.id,
sourcePaneId: pane.id,
@@ -972,7 +1012,13 @@ function App() {
window.dispatchEvent(new CustomEvent("dragonfly:refresh-terminals"));
} catch (error) {
const errorMessage = getErrorMessage(error);
logger.error("Failed to create split session", error);
logger.error({
domain: "session.lifecycle",
event: "session.split_failed",
message: "Failed to create split session",
ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined,
error,
});
if (newTabId) {
markTabConnectionFailed(newTabId, errorMessage);
}
@@ -1014,7 +1060,13 @@ function App() {
updatePaneSession(tabId, paneId, newSessionId);
} catch (error) {
const errorMessage = getErrorMessage(error);
logger.error("Failed to reconnect pane", error);
logger.error({
domain: "session.lifecycle",
event: "session.reconnect_failed",
message: "Failed to reconnect pane",
ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined,
error,
});
markPaneConnectionFailed(tabId, paneId, errorMessage);
maybePromptConnectionEdit(pane.connectionId, errorMessage, {
sourceTabId: tabId,
@@ -1058,7 +1110,13 @@ function App() {
try {
await invoke("close_session", { sessionId });
} catch (error) {
logger.error("Failed to disconnect session outside workspace", error);
logger.error({
domain: "session.lifecycle",
event: "session.close_failed",
message: "Failed to disconnect session outside workspace",
ids: { session_id: sessionId },
error,
});
toast.error(t("tabCtx.closeFailed"));
}
return;
@@ -1205,7 +1263,13 @@ function App() {
});
toast.success(t("recording.saved", { path: savedPath }));
} catch (e) {
console.error("Failed to stop recording", e);
logger.error({
domain: "session.lifecycle",
event: "recording.stop_failed",
message: "Failed to stop recording",
ids: { session_id: sessionId },
error: e,
});
}
} else {
try {
@@ -1220,7 +1284,13 @@ function App() {
return next;
});
} catch (e) {
console.error("Failed to start recording", e);
logger.error({
domain: "session.lifecycle",
event: "recording.start_failed",
message: "Failed to start recording",
ids: { session_id: sessionId },
error: e,
});
}
}
}, [activePane, activeTab, appSettings.transfer.recording_path, recordingSessions, t]);
+9 -1
View File
@@ -23,7 +23,15 @@ export default class ErrorBoundary extends Component<Props, State> {
}
componentDidCatch(error: Error, info: ErrorInfo) {
logger.error("Uncaught React error", error, info.componentStack);
logger.error({
domain: "ui.error",
event: "react.uncaught_error",
message: "Uncaught React error",
data: {
component_stack_length: info.componentStack?.length ?? 0,
},
error,
});
}
render() {
+1 -1
View File
@@ -1,7 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { MdLock, MdLockOpen } from "react-icons/md";
import { invoke } from "@/lib/invoke";
import DragonflyLogo from "../../DragonflyLogo";
interface LockScreenProps {
@@ -42,7 +42,7 @@ const IMPORT_SOURCES: ImportSource[] = [
icon: "/Xshell.svg",
extensions: ["xts"],
hint: ".xts",
type: "sessions"
type: "sessions",
},
{
id: "mobaxterm",
@@ -89,7 +89,12 @@ export default function ImportDialog({ open, onClose }: ImportDialogProps) {
toast.info(t("savedConnections.importSuccess", { count: 0 }));
}
} catch (e) {
logger.error("Import sessions failed", e);
logger.error({
domain: "settings.persistence",
event: "sessions.import_failed",
message: "Import sessions failed",
error: e,
});
toast.error(t("savedConnections.importFailed", { error: e }));
}
};
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { OtpCodePanel } from "@/components/panel/security-auth/OtpCodePanel";
@@ -13,6 +12,8 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { invoke } from "@/lib/invoke";
import { logger } from "@/lib/logger";
export interface OtpPrompt {
prompt: string;
@@ -59,8 +60,21 @@ export function OtpDialog({ request, onDone }: OtpDialogProps) {
requestId: request.requestId,
responses,
});
} catch {
/* backend handles the error */
logger.info({
domain: "security.flow",
event: "otp.response_submitted",
message: "Submitted OTP response",
ids: { request_id: request.requestId },
data: { prompt_count: responses.length },
});
} catch (error) {
logger.error({
domain: "security.flow",
event: "otp.response_submit_failed",
message: "Failed to submit OTP response",
ids: { request_id: request.requestId },
error,
});
}
onDone();
};
@@ -69,8 +83,20 @@ export function OtpDialog({ request, onDone }: OtpDialogProps) {
if (!request) return;
try {
await invoke("cancel_otp_request", { requestId: request.requestId });
} catch {
/* ignore */
logger.info({
domain: "security.flow",
event: "otp.request_cancelled",
message: "Cancelled OTP request",
ids: { request_id: request.requestId },
});
} catch (error) {
logger.error({
domain: "security.flow",
event: "otp.request_cancel_failed",
message: "Failed to cancel OTP request",
ids: { request_id: request.requestId },
error,
});
}
onDone();
};
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { MdCheck, MdContentCopy } from "react-icons/md";
@@ -11,6 +10,7 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { useApp } from "@/context/AppContext";
import { invoke } from "@/lib/invoke";
import type { TranslateResult } from "@/types/global";
interface TranslationDialogProps {
+8 -3
View File
@@ -3,7 +3,7 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
import { openPath, openUrl } from "@tauri-apps/plugin-opener";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { BiServer } from "react-icons/bi";
import { BiExport, BiImport, BiServer } from "react-icons/bi";
import { GrUpgrade } from "react-icons/gr";
import {
MdAdd,
@@ -28,13 +28,13 @@ import {
MdZoomIn,
MdZoomOut,
} from "react-icons/md";
import { BiImport, BiExport } from "react-icons/bi";
import packageJson from "@/../package.json";
import { useApp } from "@/context/AppContext";
import { useTheme } from "@/context/ThemeContext";
import { useConfigTransfer } from "@/hooks/useConfigTransfer";
import { MOD } from "@/hooks/useGlobalShortcuts";
import { AVAILABLE_LANGUAGES } from "@/i18n";
import { logger } from "@/lib/logger";
import {
DEFAULT_TERMINAL_FONT_SIZE,
decreaseTerminalFontSize,
@@ -284,7 +284,12 @@ export default function Header({
const logDir = await appLogDir();
await openPath(logDir);
} catch (error) {
console.error("Failed to open logs:", error);
logger.error({
domain: "ui.error",
event: "logs.open_failed",
message: "Failed to open logs",
error,
});
}
},
},
+1 -1
View File
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { memo, useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -6,6 +5,7 @@ import { MdClose, MdRefresh, MdSearch } from "react-icons/md";
import PanelHeader from "@/components/layout/PanelHeader";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { invoke } from "@/lib/invoke";
import type { SessionInfo } from "@/types/global";
interface ActiveSessionsProps {
+1 -1
View File
@@ -1,9 +1,9 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { memo, useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { MdChevronRight } from "react-icons/md";
import PanelHeader from "@/components/layout/PanelHeader";
import { invoke } from "@/lib/invoke";
interface CommandHistoryProps {
onCommandSend: (command: string) => void;
+1 -1
View File
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -31,6 +30,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { invoke } from "@/lib/invoke";
import type { QuickCommand, QuickCommandCategory, QuickCommandsConfig } from "@/types/global";
import { openQuickCommand } from "../../lib/windowManager";
import VariablePromptDialog, {
+1 -1
View File
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { MdSend } from "react-icons/md";
@@ -13,6 +12,7 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { invoke } from "@/lib/invoke";
import { sendSessionInput } from "@/lib/sessionInput";
interface SerialSendPanelProps {
+46 -8
View File
@@ -1,9 +1,7 @@
import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import { downloadDir, join, tempDir } from "@tauri-apps/api/path";
import { open as openDialog, save as saveDialog } from "@tauri-apps/plugin-dialog";
import { openPath } from "@tauri-apps/plugin-opener";
import {
type ComponentProps,
type KeyboardEvent as ReactKeyboardEvent,
@@ -69,6 +67,8 @@ import {
} from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useApp } from "@/context/AppContext";
import { invoke } from "@/lib/invoke";
import { logger } from "@/lib/logger";
import { sendSessionInput } from "@/lib/sessionInput";
import { formatSize } from "@/lib/utils";
import { openAutoUpload } from "@/lib/windowManager";
@@ -229,7 +229,15 @@ export default function FileExplorer({ activeSessionId, activeSessionType }: Fil
sessionId: session_id,
localPath: local_path,
remotePath: remote_path,
}).catch((err) => console.error("Auto upload failed", err));
}).catch((err) =>
logger.error({
domain: "watcher.sync",
event: "auto_upload.failed",
message: "Auto upload failed",
ids: { session_id },
error: err,
}),
);
} else {
// Trigger the window
openAutoUpload({
@@ -809,7 +817,13 @@ export default function FileExplorer({ activeSessionId, activeSessionType }: Fil
}
}
} catch (e) {
console.error("Download failed", e);
logger.error({
domain: "transfer.lifecycle",
event: "download.failed",
message: "Download failed",
ids: activeSessionId ? { session_id: activeSessionId } : undefined,
error: e,
});
}
};
@@ -853,12 +867,24 @@ export default function FileExplorer({ activeSessionId, activeSessionType }: Fil
remotePath,
});
} catch (e) {
console.error("Upload failed", e);
logger.error({
domain: "transfer.lifecycle",
event: "upload.failed",
message: "Upload failed",
ids: { session_id: activeSessionId },
error: e,
});
pendingManualRefreshUploadsRef.current.delete(uploadKey);
}
}
} catch (e) {
console.error("Upload selection failed", e);
logger.error({
domain: "transfer.lifecycle",
event: "upload.selection_failed",
message: "Upload selection failed",
ids: { session_id: activeSessionId },
error: e,
});
}
};
@@ -878,7 +904,13 @@ export default function FileExplorer({ activeSessionId, activeSessionType }: Fil
});
loadDirectory(currentPath);
} catch (e) {
console.error("Upload folder failed", e);
logger.error({
domain: "transfer.lifecycle",
event: "upload.folder_failed",
message: "Upload folder failed",
ids: { session_id: activeSessionId },
error: e,
});
}
};
@@ -895,7 +927,13 @@ export default function FileExplorer({ activeSessionId, activeSessionType }: Fil
localPath,
});
} catch (e) {
console.error("Download for open failed", e);
logger.error({
domain: "transfer.lifecycle",
event: "download.open_failed",
message: "Download for open failed",
ids: { session_id: activeSessionId },
error: e,
});
return;
}
@@ -362,7 +362,13 @@ export default function SavedConnections({
updateTabSession(tabId, sessionId);
} catch (e) {
const errorMessage = getErrorMessage(e);
logger.error(`Connection failed for "${conn.name}"`, e);
logger.error({
domain: "session.lifecycle",
event: "connection.open_failed",
message: "Connection failed",
ids: { connection_id: conn.id },
error: e,
});
toast.error(t("savedConnections.connectionFailed", { error: errorMessage }));
markTabConnectionFailed(tabId, errorMessage);
if (shouldPromptConnectionEditOnFailure(conn, errorMessage)) {
@@ -718,7 +724,12 @@ export default function SavedConnections({
await invoke("reorder_items", { connections: connsUpdates, groups: groupsUpdates });
refreshConnections();
} catch (err) {
logger.error("Drag drop failed", err);
logger.error({
domain: "ui.error",
event: "saved_connections.drag_drop_failed",
message: "Drag drop failed",
error: err,
});
}
};
@@ -778,7 +789,12 @@ export default function SavedConnections({
}
}
} catch (err) {
logger.error("Drop to root failed", err);
logger.error({
domain: "ui.error",
event: "saved_connections.drop_to_root_failed",
message: "Drop to root failed",
error: err,
});
}
};
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -14,6 +13,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { invoke } from "@/lib/invoke";
import type { SshKey } from "@/types/global";
interface KeyManagementTabProps {
@@ -1,10 +1,10 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { MdCheck, MdContentCopy, MdRefresh } from "react-icons/md";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { useOtpCode } from "@/hooks/useOtpCode";
import { invoke } from "@/lib/invoke";
import type { OtpEntry } from "@/types/global";
interface OtpCodePanelProps {
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { open as openFileDialog } from "@tauri-apps/plugin-dialog";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -35,6 +34,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { invoke } from "@/lib/invoke";
import type { OtpEntry } from "@/types/global";
interface OtpManagementTabProps {
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { MdAdd, MdDelete, MdEdit } from "react-icons/md";
@@ -13,6 +12,7 @@ import {
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { invoke } from "@/lib/invoke";
import type { SavedPassword } from "@/types/global";
interface PasswordManagementTabProps {
+1 -1
View File
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
@@ -21,6 +20,7 @@ import {
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { invoke } from "@/lib/invoke";
import type { OtpEntry, ProxyConfig, SavedPassword, SshKey } from "@/types/global";
interface SshFormProps {
+1 -1
View File
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { MdAdd, MdClose } from "react-icons/md";
@@ -11,6 +10,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { useApp } from "@/context/AppContext";
import { invoke } from "@/lib/invoke";
import {
DEFAULT_TERMINAL_FONT_SIZE,
MAX_TERMINAL_FONT_SIZE,
+71 -1
View File
@@ -1,12 +1,21 @@
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { SelectItem } from "@/components/ui/select";
import { useApp } from "@/context/AppContext";
import { useConfigTransfer } from "@/hooks/useConfigTransfer";
import { AVAILABLE_LANGUAGES } from "@/i18n";
import { SettingRow, SettingSection, SettingSelect, SettingSwitch } from "./SettingFormItems";
import {
SettingFieldGrid,
SettingRow,
SettingSection,
SettingSelect,
SettingSwitch,
} from "./SettingFormItems";
export function GeneralTab() {
const { t, i18n } = useTranslation();
const { appSettings, updateAppSettings, updateUi } = useApp();
const { handleExportDiagnostics, handleOpenLogs } = useConfigTransfer();
return (
<div className="space-y-5">
@@ -56,6 +65,67 @@ export function GeneralTab() {
/>
</SettingRow>
</SettingSection>
<SettingSection
title={t("settings.diagnostics")}
desc={t("settings.diagnosticsDesc")}
contentClassName="space-y-4"
>
<SettingFieldGrid>
<SettingSelect
label={t("settings.logLevel")}
desc={t("settings.logLevelDesc")}
value={appSettings.diagnostics.level}
onValueChange={(level) =>
updateAppSettings({
diagnostics: {
...appSettings.diagnostics,
level: level as typeof appSettings.diagnostics.level,
},
})
}
>
<SelectItem value="warn">{t("settings.logLevelWarn")}</SelectItem>
<SelectItem value="info">{t("settings.logLevelInfo")}</SelectItem>
<SelectItem value="debug">{t("settings.logLevelDebug")}</SelectItem>
</SettingSelect>
<SettingSelect
label={t("settings.logRetention")}
desc={t("settings.logRetentionDesc")}
value={String(appSettings.diagnostics.retention_days)}
onValueChange={(retentionDays) =>
updateAppSettings({
diagnostics: {
...appSettings.diagnostics,
retention_days: Number(retentionDays),
},
})
}
>
{[3, 7, 14, 30].map((days) => (
<SelectItem key={days} value={String(days)}>
{days} {t("common.days")}
</SelectItem>
))}
</SettingSelect>
</SettingFieldGrid>
<SettingRow label={t("settings.openLogs")} desc={t("settings.openLogsDesc")}>
<Button variant="outline" size="sm" onClick={handleOpenLogs}>
{t("settings.openLogs")}
</Button>
</SettingRow>
<SettingRow
label={t("settings.exportDiagnostics")}
desc={t("settings.exportDiagnosticsDesc")}
>
<Button size="sm" onClick={handleExportDiagnostics}>
{t("settings.exportDiagnostics")}
</Button>
</SettingRow>
</SettingSection>
</div>
);
}
+12 -7
View File
@@ -1,4 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import { invoke } from "@/lib/invoke";
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
import { openUrl } from "@tauri-apps/plugin-opener";
import { FitAddon } from "@xterm/addon-fit";
@@ -17,10 +17,11 @@ import { useTerminalSearch } from "@/hooks/useTerminalSearch";
import { useTerminalSettings } from "@/hooks/useTerminalSettings";
import { readClipboardText } from "@/lib/clipboard";
import { hexLuminance } from "@/lib/keywordHighlightPresets";
import { logger } from "@/lib/logger";
import {
listenSessionInputPreview,
sendSessionInput,
type SessionInputPreview,
sendSessionInput,
} from "@/lib/sessionInput";
import {
applyTerminalInputData,
@@ -330,7 +331,14 @@ export default function XTerminal({
if (!isAllowedLinkUri(uri)) return;
removeLinkPopup();
openUrl(uri).catch((err: unknown) => console.error("Failed to open link:", err));
openUrl(uri).catch((err: unknown) =>
logger.error({
domain: "ui.error",
event: "terminal.link_open_failed",
message: "Failed to open link",
error: err,
}),
);
};
const oscLinkHandler: ILinkHandler = {
@@ -1059,10 +1067,7 @@ export default function XTerminal({
? t("terminal.largeOutputProtectionActive")
: t("terminal.largeOutputProtectionRecovered")}
</div>
<div
className="mt-1 leading-5"
style={{ color: "var(--df-text-dimmed)" }}
>
<div className="mt-1 leading-5" style={{ color: "var(--df-text-dimmed)" }}>
{t(
performanceOverlay === "overloaded"
? "terminal.largeOutputProtectionActiveDetail"
+63 -12
View File
@@ -37,7 +37,7 @@ import type {
UiConfig,
} from "@/types/global";
import { invoke } from "../lib/invoke";
import { logger } from "../lib/logger";
import { logger, setLoggerLevel } from "../lib/logger";
import { DEFAULT_TERMINAL_FONT_SIZE } from "../lib/terminalFontSize";
interface AppContextType {
@@ -217,6 +217,10 @@ const DEFAULT_APP_SETTINGS: AppSettings = {
default_editor: "",
recording_path: "",
},
diagnostics: {
level: "info",
retention_days: 7,
},
ui: {
open_tabs: [],
left_width: 256,
@@ -285,6 +289,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
.then((cfg) => {
appSettingsRef.current = cfg;
setAppSettings(cfg);
setLoggerLevel(cfg.diagnostics.level);
appSettingsLoaded.current = true;
setSettingsLoaded(true);
if (cfg.security?.enable_screen_lock) {
@@ -309,11 +314,17 @@ export function AppProvider({ children }: { children: ReactNode }) {
const nextUpdates = typeof updates === "function" ? updates(prev) : updates;
const next = { ...prev, ...nextUpdates };
appSettingsRef.current = next;
setLoggerLevel(next.diagnostics.level);
if (appSettingsLoaded.current) {
if (appSettingsSaveTimerRef.current) clearTimeout(appSettingsSaveTimerRef.current);
appSettingsSaveTimerRef.current = setTimeout(() => {
invoke("save_app_settings", { settings: next }).catch((e) =>
logger.error("Failed to save app settings", e),
logger.error({
domain: "settings.persistence",
event: "settings.save_failed",
message: "Failed to save app settings",
error: e,
}),
);
}, 500);
}
@@ -344,7 +355,12 @@ export function AppProvider({ children }: { children: ReactNode }) {
setSavedConnections(saved);
setSavedGroups(groups);
} catch (e) {
logger.error("Failed to fetch connections", e);
logger.error({
domain: "ui.error",
event: "connections.fetch_failed",
message: "Failed to fetch connections",
error: e,
});
}
}, []);
@@ -750,7 +766,17 @@ export function AppProvider({ children }: { children: ReactNode }) {
invoke<string>("create_ssh_session", { connectionId: cid })
.then((sessionId) => updatePaneSession(tab.id, pane.id, sessionId))
.catch((e) => {
logger.error(`Restore SSH failed for ${pane.name}`, e);
logger.error({
domain: "session.lifecycle",
event: "session.restore_failed",
message: "Restore SSH failed",
ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined,
data: {
session_type: "SSH",
pane_id: pane.id,
},
error: e,
});
markPaneConnectionFailed(tab.id, pane.id, getErrorMessage(e));
});
break;
@@ -758,7 +784,16 @@ export function AppProvider({ children }: { children: ReactNode }) {
invoke<string>("create_local_session", { connectionId: cid || null })
.then((sessionId) => updatePaneSession(tab.id, pane.id, sessionId))
.catch((e) => {
logger.error("Restore Local failed", e);
logger.error({
domain: "session.lifecycle",
event: "session.restore_failed",
message: "Restore Local failed",
data: {
session_type: "Local",
pane_id: pane.id,
},
error: e,
});
markPaneConnectionFailed(tab.id, pane.id, getErrorMessage(e));
});
break;
@@ -770,7 +805,17 @@ export function AppProvider({ children }: { children: ReactNode }) {
invoke<string>("create_telnet_session", { connectionId: cid })
.then((sessionId) => updatePaneSession(tab.id, pane.id, sessionId))
.catch((e) => {
logger.error(`Restore Telnet failed for ${pane.name}`, e);
logger.error({
domain: "session.lifecycle",
event: "session.restore_failed",
message: "Restore Telnet failed",
ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined,
data: {
session_type: "Telnet",
pane_id: pane.id,
},
error: e,
});
markPaneConnectionFailed(tab.id, pane.id, getErrorMessage(e));
});
break;
@@ -782,7 +827,17 @@ export function AppProvider({ children }: { children: ReactNode }) {
invoke<string>("create_serial_session", { connectionId: cid })
.then((sessionId) => updatePaneSession(tab.id, pane.id, sessionId))
.catch((e) => {
logger.error(`Restore Serial failed for ${pane.name}`, e);
logger.error({
domain: "session.lifecycle",
event: "session.restore_failed",
message: "Restore Serial failed",
ids: pane.connectionId ? { connection_id: pane.connectionId } : undefined,
data: {
session_type: "Serial",
pane_id: pane.id,
},
error: e,
});
markPaneConnectionFailed(tab.id, pane.id, getErrorMessage(e));
});
break;
@@ -864,11 +919,7 @@ export function AppProvider({ children }: { children: ReactNode }) {
],
);
return (
<AppContext.Provider value={contextValue}>
{children}
</AppContext.Provider>
);
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
}
/** Hook to access AppContext. Throws if used outside AppProvider. */
+14 -7
View File
@@ -3,7 +3,7 @@ import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } fro
import type { AppSettings, Group, SavedConnection, UiConfig } from "@/types/global";
import i18n from "../i18n";
import { invoke } from "../lib/invoke";
import { logger } from "../lib/logger";
import { logger, setLoggerLevel } from "../lib/logger";
import { DEFAULT_TERMINAL_FONT_SIZE } from "../lib/terminalFontSize";
import { AppContext } from "./AppContext";
@@ -80,6 +80,10 @@ const DEFAULT_APP_SETTINGS: AppSettings = {
default_editor: "",
recording_path: "",
},
diagnostics: {
level: "info",
retention_days: 7,
},
ui: {
open_tabs: [],
left_width: 256,
@@ -121,6 +125,7 @@ export function ChildAppProvider({ children }: { children: ReactNode }) {
invoke<AppSettings>("get_app_settings")
.then((cfg) => {
setAppSettings(cfg);
setLoggerLevel(cfg.diagnostics.level);
loaded.current = true;
setSettingsLoaded(true);
if (cfg.ui?.language && cfg.ui.language !== i18n.language) {
@@ -142,11 +147,17 @@ export function ChildAppProvider({ children }: { children: ReactNode }) {
setAppSettings((prev) => {
const nextUpdates = typeof updates === "function" ? updates(prev) : updates;
const next = { ...prev, ...nextUpdates };
setLoggerLevel(next.diagnostics.level);
if (loaded.current) {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
invoke("save_app_settings", { settings: next }).catch((e) =>
logger.error("Failed to save app settings", e),
logger.error({
domain: "settings.persistence",
event: "settings.save_failed",
message: "Failed to save app settings",
error: e,
}),
);
emit("settings-changed", next).catch(() => {});
}, 500);
@@ -226,9 +237,5 @@ export function ChildAppProvider({ children }: { children: ReactNode }) {
],
);
return (
<AppContext.Provider value={contextValue}>
{children}
</AppContext.Provider>
);
return <AppContext.Provider value={contextValue}>{children}</AppContext.Provider>;
}
+1 -1
View File
@@ -1,6 +1,6 @@
import { invoke } from "@tauri-apps/api/core";
import type { Terminal } from "@xterm/xterm";
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@/lib/invoke";
import {
canSuggestFromTracker,
getTrackedCommand,
+53 -2
View File
@@ -1,4 +1,6 @@
import { appLogDir } from "@tauri-apps/api/path";
import { open as openFileDialog, save as saveFileDialog } from "@tauri-apps/plugin-dialog";
import { openPath } from "@tauri-apps/plugin-opener";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
@@ -43,7 +45,12 @@ export function useConfigTransfer() {
await invoke("export_config", { outputPath: path });
toast.success(t("settings.exportSuccess"));
} catch (error) {
logger.error("Export config failed", error);
logger.error({
domain: "settings.persistence",
event: "config.export_failed",
message: "Export config failed",
error,
});
toast.error(`${t("settings.exportFailed")}: ${error}`);
}
};
@@ -62,11 +69,53 @@ export function useConfigTransfer() {
await invoke("import_config", { filePath: path });
toast.success(t("settings.importSuccess"));
} catch (error) {
logger.error("Import config failed", error);
logger.error({
domain: "settings.persistence",
event: "config.import_failed",
message: "Import config failed",
error,
});
toast.error(`${t("settings.importFailed")}: ${error}`);
}
};
const handleOpenLogs = async () => {
try {
const logDir = await appLogDir();
await openPath(logDir);
} catch (error) {
logger.error({
domain: "ui.error",
event: "logs.open_failed",
message: "Failed to open logs",
error,
});
toast.error(t("settings.openLogsFailed"));
}
};
const handleExportDiagnostics = async () => {
const path = await saveFileDialog({
filters: [{ name: "Dragonfly Diagnostics", extensions: ["zip"] }],
defaultPath: "dragonfly-diagnostics.zip",
});
if (!path) return;
try {
await invoke("export_diagnostics", { outputPath: path });
toast.success(t("settings.exportDiagnosticsSuccess"));
} catch (error) {
logger.error({
domain: "settings.persistence",
event: "diagnostics.export_failed",
message: "Export diagnostics failed",
error,
});
toast.error(`${t("settings.exportDiagnosticsFailed")}: ${error}`);
}
};
const passwordAlert = (
<AlertDialog open={showPasswordAlert} onOpenChange={setShowPasswordAlert}>
<AlertDialogContent>
@@ -94,6 +143,8 @@ export function useConfigTransfer() {
return {
handleExport,
handleImport,
handleOpenLogs,
handleExportDiagnostics,
passwordAlert,
};
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useRef, useState } from "react";
import { invoke } from "@/lib/invoke";
import type { OtpCodeResult } from "@/types/global";
interface UseOtpCodeOptions {
+16
View File
@@ -590,6 +590,8 @@
"customEngines": "Search Engines",
"dataManagement": "Data Management",
"dataManagementDesc": "Import or export all application configuration data. Exported files are encrypted with the master password.",
"diagnostics": "Diagnostics",
"diagnosticsDesc": "Manage local structured diagnostic logs for troubleshooting and support.",
"defaultEditor": "Default Editor",
"defaultEditorDesc": "The program to use for local opening of remote files. Leave empty to use the system default application.",
"defaultEncoding": "Default Terminal Encoding",
@@ -638,6 +640,13 @@
"hostKeyPolicyDesc": "How to handle unknown SSH host keys during connection.",
"hostKeyPrompt": "Prompt (Ask user)",
"hostKeyStrict": "Strict (Reject unknown)",
"logLevel": "Log Level",
"logLevelDebug": "Debug",
"logLevelDesc": "Control how much detail is written to local diagnostic logs.",
"logLevelInfo": "Info",
"logLevelWarn": "Warn",
"logRetention": "Log Retention",
"logRetentionDesc": "Older logs beyond this window are removed automatically.",
"idleLockMinutes": "Idle Lock (minutes)",
"idleLockMinutesDesc": "Automatically lock the application after X minutes of inactivity. 0 to disable.",
"importConfig": "Import Config",
@@ -690,6 +699,9 @@
"noKeyRequired": "No key required",
"noKeys": "No SSH keys configured.",
"notConfigured": "Not Configured",
"openLogs": "Open Log Folder",
"openLogsDesc": "Open the local log folder in your system file manager.",
"openLogsFailed": "Failed to open log folder",
"passphrase": "Passphrase (optional)",
"policyAccept": "Accept (Auto-add)",
"policyPrompt": "Prompt (Ask user)",
@@ -768,6 +780,10 @@
"transfer": "Transfer",
"transferBufferSize": "Buffer Size (KB)",
"transferBufferSizeDesc": "Read/write buffer size in kilobytes for file transfers.",
"exportDiagnostics": "Export Diagnostics",
"exportDiagnosticsDesc": "Export recent logs, a runtime summary, and a manifest for troubleshooting.",
"exportDiagnosticsSuccess": "Diagnostics bundle exported successfully.",
"exportDiagnosticsFailed": "Diagnostics export failed",
"translation": "Translation",
"translationProvider": "Provider",
"translationProviders": "Translation Providers",
+16
View File
@@ -590,6 +590,8 @@
"customEngines": "搜索引擎配置",
"dataManagement": "数据管理",
"dataManagementDesc": "导入或导出应用程序的全部配置数据。导出文件使用主密码加密,请妥善保管。",
"diagnostics": "诊断日志",
"diagnosticsDesc": "管理本地结构化诊断日志,用于问题排查与反馈支持。",
"defaultEditor": "默认编辑器",
"defaultEditorDesc": "用于本地打开远程文件的程序。留空则使用系统默认应用程序。",
"defaultEncoding": "默认终端编码",
@@ -638,6 +640,13 @@
"hostKeyPolicyDesc": "连接期间处理未知 SSH 主机密钥的方法。",
"hostKeyPrompt": "提示 (询问用户)",
"hostKeyStrict": "严格 (拒绝未知)",
"logLevel": "日志级别",
"logLevelDebug": "调试",
"logLevelDesc": "控制本地诊断日志记录的详细程度。",
"logLevelInfo": "信息",
"logLevelWarn": "警告",
"logRetention": "日志保留时长",
"logRetentionDesc": "超过该时长的旧日志会被自动清理。",
"idleLockMinutes": "空闲锁定 (分钟)",
"idleLockMinutesDesc": "在 X 分钟无活动后自动锁定应用程序。设置为 0 以禁用。",
"importConfig": "导入配置",
@@ -690,6 +699,9 @@
"noKeyRequired": "无需密钥",
"noKeys": "暂无 SSH 密钥。",
"notConfigured": "未配置",
"openLogs": "打开日志目录",
"openLogsDesc": "在系统文件管理器中打开本地日志目录。",
"openLogsFailed": "打开日志目录失败",
"passphrase": "密码短语(可选)",
"policyAccept": "接受 (自动添加)",
"policyPrompt": "提示 (询问用户)",
@@ -768,6 +780,10 @@
"transfer": "传输",
"transferBufferSize": "缓冲区大小 (KB)",
"transferBufferSizeDesc": "文件传输的读写缓冲区大小(千字节)。",
"exportDiagnostics": "导出诊断包",
"exportDiagnosticsDesc": "导出最近的日志、运行时摘要和清单文件,便于定位问题。",
"exportDiagnosticsSuccess": "诊断包已成功导出。",
"exportDiagnosticsFailed": "导出诊断包失败",
"translation": "翻译",
"translationProvider": "翻译提供商",
"translationProviders": "翻译服务商",
+70 -2
View File
@@ -9,10 +9,78 @@ import { logger } from "./logger";
* const sessions = await invoke<SessionInfo[]>("list_sessions");
*/
export async function invoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
const requestId = logger.createRequestId();
const startedAt = performance.now();
const argsSummary = summarizeInvokeArgs(args);
logger.debug({
domain: "tauri.invoke",
event: "command.start",
message: `Command "${cmd}" started`,
ids: { request_id: requestId },
data: {
command: cmd,
args: argsSummary,
},
});
try {
return await tauriInvoke<T>(cmd, args);
const result = await tauriInvoke<T>(cmd, args);
logger.debug({
domain: "tauri.invoke",
event: "command.success",
message: `Command "${cmd}" succeeded`,
ids: { request_id: requestId },
data: {
command: cmd,
duration_ms: Math.round(performance.now() - startedAt),
},
});
return result;
} catch (error) {
logger.error(`Command "${cmd}" failed`, error);
logger.error({
domain: "tauri.invoke",
event: "command.error",
message: `Command "${cmd}" failed`,
ids: { request_id: requestId },
data: {
command: cmd,
duration_ms: Math.round(performance.now() - startedAt),
args: argsSummary,
},
error,
});
throw error;
}
}
function summarizeInvokeArgs(args?: Record<string, unknown>): unknown {
if (!args) return undefined;
return Object.fromEntries(
Object.entries(args).map(([key, value]) => [key, summarizeValue(value, 0)]),
);
}
function summarizeValue(value: unknown, depth: number): unknown {
if (value == null || typeof value === "boolean" || typeof value === "number") {
return value;
}
if (typeof value === "string") {
return { type: "string", length: value.length };
}
if (Array.isArray(value)) {
return { type: "array", length: value.length };
}
if (typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>);
if (depth >= 1) {
return { type: "object", keys: entries.map(([key]) => key).slice(0, 20) };
}
return Object.fromEntries(entries.map(([key, item]) => [key, summarizeValue(item, depth + 1)]));
}
return { type: typeof value };
}
+284 -52
View File
@@ -1,7 +1,39 @@
import { invoke as tauriInvoke } from "@tauri-apps/api/core";
import type { DiagnosticsLogLevel } from "@/types/global";
type LogLevel = "debug" | "info" | "warn" | "error";
type LogDomain =
| "app.lifecycle"
| "ui.action"
| "ui.error"
| "tauri.invoke"
| "settings.persistence"
| "session.lifecycle"
| "ssh.auth"
| "transfer.lifecycle"
| "watcher.sync"
| "security.flow"
| "updater.flow";
type StableLogIdKey = "session_id" | "connection_id" | "transfer_id" | "tunnel_id" | "request_id";
type LogIds = Partial<Record<StableLogIdKey, string>>;
export interface LogPayload {
domain: LogDomain;
event: string;
message: string;
ids?: LogIds;
data?: unknown;
error?: unknown;
}
interface FrontendLogEntry extends LogPayload {
timestamp: string;
level: LogLevel;
}
const LOG_LEVELS: Record<LogLevel, number> = {
debug: 0,
info: 1,
@@ -9,84 +41,284 @@ const LOG_LEVELS: Record<LogLevel, number> = {
error: 3,
};
const MIN_LEVEL: LogLevel = import.meta.env.DEV ? "debug" : "warn";
const DEFAULT_LEVEL: LogLevel = import.meta.env.DEV ? "debug" : "info";
const BATCH_DELAY_MS = 250;
const MAX_BATCH_SIZE = 50;
let minLevel: LogLevel = DEFAULT_LEVEL;
const queue: FrontendLogEntry[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
let flushInFlight = false;
let lifecycleFlushRegistered = false;
export function setLoggerLevel(level: DiagnosticsLogLevel): void {
minLevel = level;
}
function shouldLog(level: LogLevel): boolean {
return LOG_LEVELS[level] >= LOG_LEVELS[MIN_LEVEL];
return LOG_LEVELS[level] >= LOG_LEVELS[minLevel];
}
function formatMessage(level: LogLevel, message: string): string {
const now = new Date();
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}.${pad(now.getMilliseconds(), 3)}`;
return `[${timestamp}] [${level.toUpperCase()}] ${message}`;
function createRequestId(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `req-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function serializeLogArg(arg: unknown): string {
if (arg instanceof Error) {
return arg.stack ?? `${arg.name}: ${arg.message}`;
function formatTimestamp(date = new Date()): string {
const pad = (value: number, width = 2) => String(value).padStart(width, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}`;
}
function formatConsoleLine(entry: FrontendLogEntry): string {
return `[${entry.timestamp}] [${entry.level.toUpperCase()}] [${entry.domain}/${entry.event}] ${entry.message}`;
}
function normalizeError(error: unknown): unknown {
if (error instanceof Error) {
return {
name: error.name,
message_hash: hashString(error.message),
stack_hash: error.stack ? hashString(error.stack) : undefined,
};
}
return error;
}
function hashString(value: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < value.length; index += 1) {
hash ^= value.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16).padStart(8, "0");
}
function normalizeKey(key: string): string {
return key
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "");
}
function isRedactedKey(key: string): boolean {
const normalized = normalizeKey(key);
return (
normalized === "password" ||
normalized === "secret" ||
normalized === "token" ||
normalized === "otp" ||
normalized === "command" ||
normalized === "content" ||
normalized === "clipboard" ||
normalized === "passphrase" ||
normalized === "master_password" ||
normalized.endsWith("_password") ||
normalized.endsWith("_secret") ||
normalized.endsWith("_token") ||
normalized.endsWith("_otp") ||
normalized.endsWith("_command") ||
normalized.endsWith("_content") ||
normalized.endsWith("_clipboard") ||
normalized.endsWith("_passphrase") ||
normalized.includes("private_key") ||
normalized.includes("public_key") ||
normalized.includes("key_data") ||
normalized.includes("secret_key") ||
(normalized.endsWith("_key") && !normalized.endsWith("_id"))
);
}
function isHostKey(key: string): boolean {
const normalized = normalizeKey(key);
return normalized === "host" || normalized.endsWith("_host");
}
function isUsernameKey(key: string): boolean {
const normalized = normalizeKey(key);
return normalized === "username" || normalized === "user" || normalized.endsWith("_username");
}
function isPathKey(key: string): boolean {
const normalized = normalizeKey(key);
return normalized === "path" || normalized === "cwd" || normalized.endsWith("_path");
}
function sanitizeString(key: string | undefined, value: string): unknown {
if (!key) return value;
if (isRedactedKey(key)) return "[REDACTED]";
if (isHostKey(key)) return { type: "host", hash: hashString(value) };
if (isUsernameKey(key)) return { type: "username", hash: hashString(value) };
if (isPathKey(key)) {
const extension = (() => {
const lastDot = value.lastIndexOf(".");
if (lastDot <= value.lastIndexOf("/") || lastDot <= value.lastIndexOf("\\")) {
return undefined;
}
return value.slice(lastDot + 1);
})();
return {
type: "path",
hash: hashString(value),
...(extension ? { extension } : {}),
};
}
return value;
}
function sanitizeValue(value: unknown, key?: string): unknown {
if (value instanceof Error) {
return sanitizeValue(normalizeError(value), key);
}
if (typeof arg === "string") {
return arg;
if (Array.isArray(value)) {
return value.map((item) => sanitizeValue(item, key));
}
if (
typeof arg === "number" ||
typeof arg === "boolean" ||
typeof arg === "bigint" ||
typeof arg === "symbol" ||
arg == null
) {
return String(arg);
if (typeof value === "string") {
return sanitizeString(key, value);
}
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(([childKey, childValue]) => [
childKey,
sanitizeValue(childValue, childKey),
]),
);
}
return value;
}
function normalizePayload(level: LogLevel, payload: LogPayload): FrontendLogEntry {
return {
...payload,
timestamp: formatTimestamp(),
level,
ids: payload.ids ? (sanitizeValue(payload.ids, "ids") as LogIds) : undefined,
data: payload.data === undefined ? undefined : sanitizeValue(payload.data, "data"),
error:
payload.error === undefined
? undefined
: sanitizeValue(normalizeError(payload.error), "error"),
};
}
function writeConsole(entry: FrontendLogEntry): void {
const line = formatConsoleLine(entry);
const extras = [entry.ids, entry.data, entry.error].filter((item) => item !== undefined);
switch (entry.level) {
case "debug":
console.debug(line, ...extras);
break;
case "info":
console.info(line, ...extras);
break;
case "warn":
console.warn(line, ...extras);
break;
case "error":
console.error(line, ...extras);
break;
}
}
function scheduleFlush(): void {
if (flushTimer) return;
flushTimer = setTimeout(() => {
flushTimer = null;
void flushQueue();
}, BATCH_DELAY_MS);
}
async function flushQueue(): Promise<void> {
if (flushInFlight) return;
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
if (queue.length === 0) return;
flushInFlight = true;
const batch = queue.splice(0, MAX_BATCH_SIZE);
try {
return JSON.stringify(arg);
} catch {
return String(arg);
await tauriInvoke("append_frontend_logs", { entries: batch });
} catch (error) {
console.error(
`[${formatTimestamp()}] [ERROR] [ui.error/logger.flush_failed] Failed to persist frontend log batch`,
error,
);
} finally {
flushInFlight = false;
if (queue.length >= MAX_BATCH_SIZE) {
void flushQueue();
} else if (queue.length > 0) {
scheduleFlush();
}
}
}
function persistLog(level: LogLevel, message: string, args: unknown[]): void {
if (level !== "warn" && level !== "error") {
return;
function registerLifecycleFlush(): void {
if (lifecycleFlushRegistered) return;
if (typeof window === "undefined") return;
const flush = () => {
void flushQueue();
};
window.addEventListener("pagehide", flush);
window.addEventListener("beforeunload", flush);
if (typeof document !== "undefined") {
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") {
flush();
}
});
}
const context = args.map(serializeLogArg).join("\n");
void tauriInvoke("write_log", {
level,
message,
context: context || null,
}).catch(() => {});
lifecycleFlushRegistered = true;
}
function emit(level: LogLevel, payload: LogPayload): void {
if (!shouldLog(level)) return;
const entry = normalizePayload(level, payload);
writeConsole(entry);
queue.push(entry);
if (level === "warn" || level === "error" || queue.length >= MAX_BATCH_SIZE) {
void flushQueue();
} else {
scheduleFlush();
}
}
/** Level-filtered logger: debug/info/warn/error. Respects DEV vs prod min level. */
export const logger = {
debug(message: string, ...args: unknown[]) {
if (shouldLog("debug")) {
console.debug(formatMessage("debug", message), ...args);
}
debug(payload: LogPayload) {
emit("debug", payload);
},
info(message: string, ...args: unknown[]) {
if (shouldLog("info")) {
console.info(formatMessage("info", message), ...args);
}
info(payload: LogPayload) {
emit("info", payload);
},
warn(message: string, ...args: unknown[]) {
if (shouldLog("warn")) {
console.warn(formatMessage("warn", message), ...args);
persistLog("warn", message, args);
}
warn(payload: LogPayload) {
emit("warn", payload);
},
error(message: string, ...args: unknown[]) {
if (shouldLog("error")) {
console.error(formatMessage("error", message), ...args);
persistLog("error", message, args);
}
error(payload: LogPayload) {
emit("error", payload);
},
flush(): Promise<void> {
return flushQueue();
},
createRequestId,
};
registerLifecycleFlush();
+1 -1
View File
@@ -1,4 +1,4 @@
import { invoke } from "@tauri-apps/api/core";
import { invoke } from "./invoke";
const SESSION_INPUT_PREVIEW_EVENT = "dragonfly:session-input-preview";
+92 -22
View File
@@ -1,5 +1,6 @@
import { relaunch } from "@tauri-apps/plugin-process";
import { check, type Update } from "@tauri-apps/plugin-updater";
import { logger } from "@/lib/logger";
export type UpdateStatus = "idle" | "checking" | "available" | "downloading" | "ready" | "error";
@@ -17,15 +18,48 @@ export interface UpdateInfo {
let cachedUpdate: Update | null = null;
export async function checkForUpdate(): Promise<UpdateInfo | null> {
const update = await check();
if (!update) return null;
logger.info({
domain: "updater.flow",
event: "update.check_started",
message: "Checking for updates",
});
cachedUpdate = update;
return {
version: update.version,
date: update.date,
body: update.body,
};
try {
const update = await check();
if (!update) {
cachedUpdate = null;
logger.info({
domain: "updater.flow",
event: "update.check_succeeded",
message: "No update available",
});
return null;
}
cachedUpdate = update;
logger.info({
domain: "updater.flow",
event: "update.available",
message: "Update available",
data: {
version: update.version,
release_date: update.date,
},
});
return {
version: update.version,
date: update.date,
body: update.body,
};
} catch (error) {
logger.error({
domain: "updater.flow",
event: "update.check_failed",
message: "Update check failed",
error,
});
throw error;
}
}
export async function downloadAndInstallUpdate(
@@ -36,23 +70,59 @@ export async function downloadAndInstallUpdate(
let downloaded = 0;
let total = 0;
await cachedUpdate.downloadAndInstall((event) => {
switch (event.event) {
case "Started":
total = event.data.contentLength ?? 0;
onProgress?.({ downloaded: 0, total });
break;
case "Progress":
downloaded += event.data.chunkLength;
onProgress?.({ downloaded, total });
break;
case "Finished":
onProgress?.({ downloaded: total, total });
break;
}
logger.info({
domain: "updater.flow",
event: "update.download_started",
message: "Starting update download and install",
data: {
version: cachedUpdate.version,
},
});
try {
await cachedUpdate.downloadAndInstall((event) => {
switch (event.event) {
case "Started":
total = event.data.contentLength ?? 0;
onProgress?.({ downloaded: 0, total });
break;
case "Progress":
downloaded += event.data.chunkLength;
onProgress?.({ downloaded, total });
break;
case "Finished":
onProgress?.({ downloaded: total, total });
logger.info({
domain: "updater.flow",
event: "update.download_finished",
message: "Update download finished",
data: {
version: cachedUpdate?.version,
byte_size: total,
},
});
break;
}
});
} catch (error) {
logger.error({
domain: "updater.flow",
event: "update.install_failed",
message: "Update install failed",
data: {
version: cachedUpdate.version,
},
error,
});
throw error;
}
}
export async function relaunchApp(): Promise<void> {
logger.info({
domain: "updater.flow",
event: "update.relaunch_requested",
message: "Relaunching app after update",
});
await relaunch();
}
+7 -1
View File
@@ -1,5 +1,6 @@
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
import { logger } from "./logger";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
@@ -28,7 +29,12 @@ export function parseJsonSearchParam<T>(value: string | null): T | null {
// URLSearchParams.get() already returns a decoded value.
return JSON.parse(value) as T;
} catch (error) {
console.error("Failed to parse JSON search param", error);
logger.error({
domain: "ui.error",
event: "url_param.parse_failed",
message: "Failed to parse JSON search param",
error,
});
return null;
}
}
+9 -2
View File
@@ -1,10 +1,11 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useTranslation } from "react-i18next";
import { MdCloudSync } from "react-icons/md";
import ChildWindowHeader from "@/components/layout/ChildWindowHeader";
import { Button } from "@/components/ui/button";
import { invoke } from "@/lib/invoke";
import { logger } from "@/lib/logger";
import { parseJsonSearchParam } from "@/lib/utils";
export interface AutoUploadDialogData {
@@ -41,7 +42,13 @@ export default function AutoUploadPage() {
remotePath: data.remotePath,
});
} catch (e) {
console.error("Upload failed", e);
logger.error({
domain: "transfer.lifecycle",
event: "upload.auto_upload_failed",
message: "Upload failed",
ids: { session_id: data.sessionId },
error: e,
});
}
handleClose();
+3 -6
View File
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
@@ -21,6 +20,7 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { invoke } from "@/lib/invoke";
import type { Group, OtpEntry, ProxyConfig, SavedConnection } from "@/types/global";
export default function NewSessionPage() {
@@ -343,12 +343,9 @@ export default function NewSessionPage() {
username,
auth: {
mode: authType,
password_id:
authType === "password" ? (passwordId || "") : undefined,
password_id: authType === "password" ? passwordId || "" : undefined,
password:
authType === "password"
? password || (hasPassword ? undefined : "")
: undefined,
authType === "password" ? password || (hasPassword ? undefined : "") : undefined,
key_id: authType === "key" && keyId ? keyId : undefined,
otp_id: otpId || undefined,
auto_fill_otp: otpId ? autoFillOtp : undefined,
+1 -1
View File
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { type CSSProperties, useEffect, useState } from "react";
@@ -19,6 +18,7 @@ import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { invoke } from "@/lib/invoke";
import { parseJsonSearchParam } from "@/lib/utils";
import type { QuickCommand, QuickCommandCategory } from "@/types/global";
+8
View File
@@ -452,6 +452,13 @@ export interface TransferSettings {
recording_path: string;
}
export type DiagnosticsLogLevel = "warn" | "info" | "debug";
export interface DiagnosticsSettings {
level: DiagnosticsLogLevel;
retention_days: number;
}
export interface TunnelConfig {
id: string;
name: string;
@@ -483,6 +490,7 @@ export interface AppSettings {
terminal: TerminalSettings;
interaction: InteractionSettings;
transfer: TransferSettings;
diagnostics: DiagnosticsSettings;
ui: UiConfig;
}