feat(logging): integrate tauri-plugin-log with rotating logs and settings UI (resolves #139) #142
@@ -23,6 +23,8 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["process", "io-util", "sync", "time", "rt-multi-thread", "macros"] }
|
||||
tauri-plugin-dialog = "2.7.2"
|
||||
tauri-plugin-log = "2"
|
||||
log = "0.4"
|
||||
base64 = "0.22"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "tiff"] }
|
||||
|
||||
|
||||
@@ -121,6 +121,51 @@ pub fn get_default_working_dir(app: AppHandle) -> Result<String, String> {
|
||||
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]
|
||||
pub async fn select_target_file(
|
||||
app: AppHandle,
|
||||
|
||||
@@ -8,12 +8,31 @@ mod settings;
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.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(print::PrinterDevModeStore::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::spawn_process,
|
||||
commands::get_app_info,
|
||||
commands::get_default_working_dir,
|
||||
commands::get_log_path,
|
||||
commands::open_log_dir,
|
||||
commands::select_target_file,
|
||||
commands::select_directory,
|
||||
commands::send_stdin,
|
||||
|
||||
@@ -51,6 +51,7 @@ impl ProcessManager {
|
||||
command.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
|
||||
log::info!(target: "subprocess", "Spawning process '{id}': {binary} {:?}", args);
|
||||
match command.spawn() {
|
||||
Ok(mut child) => {
|
||||
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();
|
||||
crate::events::emit_json_row(&app_clone, &id_clone, json_str);
|
||||
} else {
|
||||
log::info!(target: "subprocess", "[{id_clone}] {line}");
|
||||
emit_stdout(&app_clone, &id_clone, line);
|
||||
}
|
||||
}
|
||||
@@ -77,6 +79,7 @@ impl ProcessManager {
|
||||
tokio::spawn(async move {
|
||||
let mut reader = BufReader::new(stderr).lines();
|
||||
while let Ok(Some(line)) = reader.next_line().await {
|
||||
log::warn!(target: "subprocess", "[{id_clone2}] [stderr] {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
|
||||
{
|
||||
let mut stdins = stdins_clone.lock().await;
|
||||
@@ -126,6 +131,7 @@ impl ProcessManager {
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(target: "subprocess", "Failed to spawn '{id}': {e}");
|
||||
emit_error(&app, &id, e.to_string());
|
||||
Err(e.to_string())
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ pub struct ProfilingPreset {
|
||||
pub struct AppSettings {
|
||||
pub argyll_binary_dir: Option<String>,
|
||||
pub default_instrument: Option<String>,
|
||||
pub log_level: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_presets: Vec<ProfilingPreset>,
|
||||
}
|
||||
|
||||
@@ -489,6 +489,26 @@
|
||||
<label for="default_instrument">Default Instrument Override</label>
|
||||
<input type="text" id="default_instrument" placeholder="e.g. i1">
|
||||
</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 & 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">
|
||||
<button id="saveSettingsBtn" class="primary">Save Settings</button>
|
||||
<button id="closeSettingsBtn" class="secondary">Cancel</button>
|
||||
|
||||
+53
-4
@@ -5,31 +5,80 @@ export async function initSettings() {
|
||||
const openBtn = document.getElementById('openSettingsBtn');
|
||||
const saveBtn = document.getElementById('saveSettingsBtn');
|
||||
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;
|
||||
|
||||
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 () => {
|
||||
try {
|
||||
const settings = await invoke('load_settings');
|
||||
document.getElementById('argyll_binary_dir').value = settings.argyll_binary_dir || '';
|
||||
document.getElementById('default_instrument').value = settings.default_instrument || '';
|
||||
if (logLevelSelect && settings.log_level) {
|
||||
logLevelSelect.value = settings.log_level;
|
||||
}
|
||||
await refreshLogPath();
|
||||
dialog.showModal();
|
||||
} catch (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) {
|
||||
closeBtn.addEventListener('click', () => dialog.close());
|
||||
}
|
||||
|
||||
if (saveBtn) {
|
||||
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 {
|
||||
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 });
|
||||
dialog.close();
|
||||
} catch (e) {
|
||||
|
||||
Reference in New Issue
Block a user