feat(logging): introduce console usage linting and enhance error logging structure across components for improved diagnostics
This commit is contained in:
1 parent
517583fbf1
commit
a37090e70d
57 files changed
+2618
-378
No files matched your search
+2
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Generated
+13
@@ -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]]
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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()))
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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]);
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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,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,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,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,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 {
|
||||
|
||||
@@ -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,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,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,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
}
|
||||
+295
-63
@@ -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)}`;
|
||||
}
|
||||
|
||||
if (typeof arg === "string") {
|
||||
return arg;
|
||||
function formatConsoleLine(entry: FrontendLogEntry): string {
|
||||
return `[${entry.timestamp}] [${entry.level.toUpperCase()}] [${entry.domain}/${entry.event}] ${entry.message}`;
|
||||
}
|
||||
|
||||
if (
|
||||
typeof arg === "number" ||
|
||||
typeof arg === "boolean" ||
|
||||
typeof arg === "bigint" ||
|
||||
typeof arg === "symbol" ||
|
||||
arg == null
|
||||
) {
|
||||
return String(arg);
|
||||
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 (Array.isArray(value)) {
|
||||
return value.map((item) => sanitizeValue(item, key));
|
||||
}
|
||||
|
||||
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 context = args.map(serializeLogArg).join("\n");
|
||||
|
||||
void tauriInvoke("write_log", {
|
||||
level,
|
||||
message,
|
||||
context: context || null,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
},
|
||||
|
||||
info(message: string, ...args: unknown[]) {
|
||||
if (shouldLog("info")) {
|
||||
console.info(formatMessage("info", message), ...args);
|
||||
}
|
||||
},
|
||||
|
||||
warn(message: string, ...args: unknown[]) {
|
||||
if (shouldLog("warn")) {
|
||||
console.warn(formatMessage("warn", message), ...args);
|
||||
persistLog("warn", message, args);
|
||||
}
|
||||
},
|
||||
|
||||
error(message: string, ...args: unknown[]) {
|
||||
if (shouldLog("error")) {
|
||||
console.error(formatMessage("error", message), ...args);
|
||||
persistLog("error", message, args);
|
||||
}
|
||||
},
|
||||
const flush = () => {
|
||||
void flushQueue();
|
||||
};
|
||||
|
||||
window.addEventListener("pagehide", flush);
|
||||
window.addEventListener("beforeunload", flush);
|
||||
|
||||
if (typeof document !== "undefined") {
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "hidden") {
|
||||
flush();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug(payload: LogPayload) {
|
||||
emit("debug", payload);
|
||||
},
|
||||
|
||||
info(payload: LogPayload) {
|
||||
emit("info", payload);
|
||||
},
|
||||
|
||||
warn(payload: LogPayload) {
|
||||
emit("warn", payload);
|
||||
},
|
||||
|
||||
error(payload: LogPayload) {
|
||||
emit("error", payload);
|
||||
},
|
||||
|
||||
flush(): Promise<void> {
|
||||
return flushQueue();
|
||||
},
|
||||
|
||||
createRequestId,
|
||||
};
|
||||
|
||||
registerLifecycleFlush();
|
||||
@@ -1,4 +1,4 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { invoke } from "./invoke";
|
||||
|
||||
const SESSION_INPUT_PREVIEW_EVENT = "dragonfly:session-input-preview";
|
||||
|
||||
|
||||
+71
-1
@@ -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> {
|
||||
logger.info({
|
||||
domain: "updater.flow",
|
||||
event: "update.check_started",
|
||||
message: "Checking for updates",
|
||||
});
|
||||
|
||||
try {
|
||||
const update = await check();
|
||||
if (!update) return null;
|
||||
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,6 +70,16 @@ export async function downloadAndInstallUpdate(
|
||||
let downloaded = 0;
|
||||
let total = 0;
|
||||
|
||||
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":
|
||||
@@ -48,11 +92,37 @@ export async function downloadAndInstallUpdate(
|
||||
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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,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";
|
||||
|
||||
|
||||
Vendored
+8
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user