feat(logging): integrate tauri-plugin-log with rotating logs and settings UI (resolves #139) #142

Merged
gronod merged 1 commits from feat/logging-139 into development 2026-08-29 17:48:23 +01:00
7 changed files with 146 additions and 4 deletions
+2
View File
@@ -23,6 +23,8 @@ serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tokio = { version = "1", features = ["process", "io-util", "sync", "time", "rt-multi-thread", "macros"] } tokio = { version = "1", features = ["process", "io-util", "sync", "time", "rt-multi-thread", "macros"] }
tauri-plugin-dialog = "2.7.2" tauri-plugin-dialog = "2.7.2"
tauri-plugin-log = "2"
log = "0.4"
base64 = "0.22" base64 = "0.22"
image = { version = "0.25", default-features = false, features = ["png", "tiff"] } image = { version = "0.25", default-features = false, features = ["png", "tiff"] }
+45
View File
@@ -121,6 +121,51 @@ pub fn get_default_working_dir(app: AppHandle) -> Result<String, String> {
resolve_safe_cwd(&app, "") resolve_safe_cwd(&app, "")
} }
#[tauri::command]
pub fn get_log_path(app: AppHandle) -> Result<String, String> {
let log_dir = app
.path()
.app_log_dir()
.map_err(|e| format!("Failed to resolve log dir: {}", e))?;
Ok(log_dir.join("iccery.log").to_string_lossy().to_string())
}
#[tauri::command]
pub fn open_log_dir(app: AppHandle) -> Result<(), String> {
let log_dir = app
.path()
.app_log_dir()
.map_err(|e| format!("Failed to resolve log dir: {}", e))?;
std::fs::create_dir_all(&log_dir).map_err(|e| format!("Failed to create log dir: {}", e))?;
#[cfg(target_os = "windows")]
{
std::process::Command::new("explorer")
.arg(&log_dir)
.spawn()
.map_err(|e| format!("Failed to open log folder: {}", e))?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(&log_dir)
.spawn()
.map_err(|e| format!("Failed to open log folder: {}", e))?;
}
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&log_dir)
.spawn()
.map_err(|e| format!("Failed to open log folder: {}", e))?;
}
Ok(())
}
#[tauri::command] #[tauri::command]
pub async fn select_target_file( pub async fn select_target_file(
app: AppHandle, app: AppHandle,
+19
View File
@@ -8,12 +8,31 @@ mod settings;
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(
tauri_plugin_log::Builder::default()
.targets([
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::LogDir {
file_name: Some("iccery".into()),
}),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Stdout),
tauri_plugin_log::Target::new(tauri_plugin_log::TargetKind::Webview),
])
.level(if cfg!(debug_assertions) {
log::LevelFilter::Debug
} else {
log::LevelFilter::Info
})
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepOne)
.build(),
)
.manage(process_manager::ProcessManager::new()) .manage(process_manager::ProcessManager::new())
.manage(print::PrinterDevModeStore::new()) .manage(print::PrinterDevModeStore::new())
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::spawn_process, commands::spawn_process,
commands::get_app_info, commands::get_app_info,
commands::get_default_working_dir, commands::get_default_working_dir,
commands::get_log_path,
commands::open_log_dir,
commands::select_target_file, commands::select_target_file,
commands::select_directory, commands::select_directory,
commands::send_stdin, commands::send_stdin,
+6
View File
@@ -51,6 +51,7 @@ impl ProcessManager {
command.creation_flags(CREATE_NO_WINDOW); command.creation_flags(CREATE_NO_WINDOW);
} }
log::info!(target: "subprocess", "Spawning process '{id}': {binary} {:?}", args);
match command.spawn() { match command.spawn() {
Ok(mut child) => { Ok(mut child) => {
let stdout = child.stdout.take().expect("Failed to open stdout"); let stdout = child.stdout.take().expect("Failed to open stdout");
@@ -67,6 +68,7 @@ impl ProcessManager {
let json_str = line[JSON_ROW_PREFIX.len()..].to_string(); let json_str = line[JSON_ROW_PREFIX.len()..].to_string();
crate::events::emit_json_row(&app_clone, &id_clone, json_str); crate::events::emit_json_row(&app_clone, &id_clone, json_str);
} else { } else {
log::info!(target: "subprocess", "[{id_clone}] {line}");
emit_stdout(&app_clone, &id_clone, line); emit_stdout(&app_clone, &id_clone, line);
} }
} }
@@ -77,6 +79,7 @@ impl ProcessManager {
tokio::spawn(async move { tokio::spawn(async move {
let mut reader = BufReader::new(stderr).lines(); let mut reader = BufReader::new(stderr).lines();
while let Ok(Some(line)) = reader.next_line().await { while let Ok(Some(line)) = reader.next_line().await {
log::warn!(target: "subprocess", "[{id_clone2}] [stderr] {line}");
emit_stderr(&app_clone2, &id_clone2, line); emit_stderr(&app_clone2, &id_clone2, line);
} }
}); });
@@ -112,6 +115,8 @@ impl ProcessManager {
} }
}; };
log::info!(target: "subprocess", "Process '{id_clone_exit}' exited with code {exit_code}");
// Reap child from process manager maps upon exit // Reap child from process manager maps upon exit
{ {
let mut stdins = stdins_clone.lock().await; let mut stdins = stdins_clone.lock().await;
@@ -126,6 +131,7 @@ impl ProcessManager {
Ok(()) Ok(())
} }
Err(e) => { Err(e) => {
log::error!(target: "subprocess", "Failed to spawn '{id}': {e}");
emit_error(&app, &id, e.to_string()); emit_error(&app, &id, e.to_string());
Err(e.to_string()) Err(e.to_string())
} }
+1
View File
@@ -24,6 +24,7 @@ pub struct ProfilingPreset {
pub struct AppSettings { pub struct AppSettings {
pub argyll_binary_dir: Option<String>, pub argyll_binary_dir: Option<String>,
pub default_instrument: Option<String>, pub default_instrument: Option<String>,
pub log_level: Option<String>,
#[serde(default)] #[serde(default)]
pub custom_presets: Vec<ProfilingPreset>, pub custom_presets: Vec<ProfilingPreset>,
} }
+20
View File
@@ -489,6 +489,26 @@
<label for="default_instrument">Default Instrument Override</label> <label for="default_instrument">Default Instrument Override</label>
<input type="text" id="default_instrument" placeholder="e.g. i1"> <input type="text" id="default_instrument" placeholder="e.g. i1">
</div> </div>
<div class="form-group" style="margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border-color, #333);">
<label style="font-weight: 600;">Diagnostics &amp; Logging</label>
<div class="input-row" style="margin-top: 8px;">
<div>
<label for="logLevelSelect" class="sub-label">Log Level</label>
<select id="logLevelSelect">
<option value="error">Error (Minimal)</option>
<option value="warn">Warn</option>
<option value="info" selected>Info (Standard)</option>
<option value="debug">Debug (Detailed)</option>
<option value="trace">Trace (Verbose)</option>
</select>
</div>
<div style="display: flex; align-items: flex-end; gap: 8px;">
<button type="button" id="btnOpenLogFolder" class="secondary" title="Open log folder in file explorer">📂 Open Log Folder</button>
<button type="button" id="btnCopyLogPath" class="secondary" title="Copy log path to clipboard">📋 Copy Path</button>
</div>
</div>
<small id="logPathDisplay" class="path-display" style="margin-top: 6px; display: block;"></small>
</div>
<div class="modal-actions"> <div class="modal-actions">
<button id="saveSettingsBtn" class="primary">Save Settings</button> <button id="saveSettingsBtn" class="primary">Save Settings</button>
<button id="closeSettingsBtn" class="secondary">Cancel</button> <button id="closeSettingsBtn" class="secondary">Cancel</button>
+53 -4
View File
@@ -5,31 +5,80 @@ export async function initSettings() {
const openBtn = document.getElementById('openSettingsBtn'); const openBtn = document.getElementById('openSettingsBtn');
const saveBtn = document.getElementById('saveSettingsBtn'); const saveBtn = document.getElementById('saveSettingsBtn');
const closeBtn = document.getElementById('closeSettingsBtn'); const closeBtn = document.getElementById('closeSettingsBtn');
const logLevelSelect = document.getElementById('logLevelSelect');
const btnOpenLogFolder = document.getElementById('btnOpenLogFolder');
const btnCopyLogPath = document.getElementById('btnCopyLogPath');
const logPathDisplay = document.getElementById('logPathDisplay');
if (!dialog || !openBtn) return; if (!dialog || !openBtn) return;
async function refreshLogPath() {
if (!logPathDisplay) return;
try {
const path = await invoke('get_log_path');
logPathDisplay.textContent = path ? `Log file: ${path}` : '';
} catch (err) {
console.warn("Could not retrieve log path:", err);
}
}
openBtn.addEventListener('click', async () => { openBtn.addEventListener('click', async () => {
try { try {
const settings = await invoke('load_settings'); const settings = await invoke('load_settings');
document.getElementById('argyll_binary_dir').value = settings.argyll_binary_dir || ''; document.getElementById('argyll_binary_dir').value = settings.argyll_binary_dir || '';
document.getElementById('default_instrument').value = settings.default_instrument || ''; document.getElementById('default_instrument').value = settings.default_instrument || '';
if (logLevelSelect && settings.log_level) {
logLevelSelect.value = settings.log_level;
}
await refreshLogPath();
dialog.showModal(); dialog.showModal();
} catch (e) { } catch (e) {
console.error("Failed to load settings:", e); console.error("Failed to load settings:", e);
} }
}); });
if (btnOpenLogFolder) {
btnOpenLogFolder.addEventListener('click', async () => {
try {
await invoke('open_log_dir');
} catch (err) {
alert(`Failed to open log folder: ${err}`);
}
});
}
if (btnCopyLogPath) {
btnCopyLogPath.addEventListener('click', async () => {
try {
const path = await invoke('get_log_path');
if (path) {
await navigator.clipboard.writeText(path);
const originalText = btnCopyLogPath.textContent;
btnCopyLogPath.textContent = '✓ Copied!';
setTimeout(() => {
btnCopyLogPath.textContent = originalText;
}, 2000);
}
} catch (err) {
alert(`Failed to copy log path: ${err}`);
}
});
}
if (closeBtn) { if (closeBtn) {
closeBtn.addEventListener('click', () => dialog.close()); closeBtn.addEventListener('click', () => dialog.close());
} }
if (saveBtn) { if (saveBtn) {
saveBtn.addEventListener('click', async () => { saveBtn.addEventListener('click', async () => {
const settings = {
argyll_binary_dir: document.getElementById('argyll_binary_dir').value.trim() || null,
default_instrument: document.getElementById('default_instrument').value.trim() || null
};
try { try {
const currentSettings = await invoke('load_settings').catch(() => ({}));
const settings = {
...currentSettings,
argyll_binary_dir: document.getElementById('argyll_binary_dir').value.trim() || null,
default_instrument: document.getElementById('default_instrument').value.trim() || null,
log_level: logLevelSelect ? logLevelSelect.value : (currentSettings.log_level || 'info'),
};
await invoke('save_settings', { settings }); await invoke('save_settings', { settings });
dialog.close(); dialog.close();
} catch (e) { } catch (e) {