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"
|
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"] }
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 & 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
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user