diff --git a/desktop/src-tauri/src/main.rs b/desktop/src-tauri/src/main.rs index e1099147a4f..9a296d882ee 100644 --- a/desktop/src-tauri/src/main.rs +++ b/desktop/src-tauri/src/main.rs @@ -56,8 +56,9 @@ use crate::tauri_handlers::helpers::{ use tauri_plugin_updater::UpdaterExt; use crate::utils::process_monitor::{ - GetProcessLogsRequest, LogEntry, LogStorage, RunningProcesses, get_log_storage, - get_process_logs, init_process_monitoring, register_process, unregister_process, + GetProcessLogsRequest, LogEntry, LogStorage, RunningProcesses, clear_process_logs, + get_log_storage, get_process_logs, init_process_monitoring, register_process, + unregister_process, }; use crate::uninstall::uninstall_application; @@ -91,6 +92,11 @@ fn get_process_logs_history( get_process_logs(&state.0.clone(), request) } +#[tauri::command] +fn clear_process_logs_history(state: State, process_id: String) -> bool { + clear_process_logs(&state.0, &process_id) +} + async fn check_and_apply_update(app: AppHandle, always_prompt: bool) { let show_error = |app: &AppHandle, title: &str, message: String| { app.dialog() @@ -526,6 +532,7 @@ fn main() { register_process_monitoring, unregister_process_monitoring, get_process_logs_history, + clear_process_logs_history, open_jupyter_logs_window, update_jupyter_status, open_backend_logs_window, diff --git a/desktop/src-tauri/src/tauri_handlers/jupyter.rs b/desktop/src-tauri/src/tauri_handlers/jupyter.rs index f9d2ca8d356..95bbcd36d7d 100644 --- a/desktop/src-tauri/src/tauri_handlers/jupyter.rs +++ b/desktop/src-tauri/src/tauri_handlers/jupyter.rs @@ -391,14 +391,31 @@ pub async fn stop_jupyter_server_impl( if let Ok(pid) = pid_str.parse::() { log::debug!("🎯 Found PID {pid} using port {port}, terminating..."); - // Kill this specific PID - let kill_result = env_sys + let term_result = env_sys .new_command("kill") - .args(["-9", &pid.to_string()]) + .args(["-15", &pid.to_string()]) .output(); - match kill_result { + match term_result { Ok(_) => { + log::debug!( + "Sent SIGTERM to PID {pid}, waiting for graceful shutdown..." + ); + std::thread::sleep(std::time::Duration::from_secs(2)); + + let check = env_sys + .new_command("kill") + .args(["-0", &pid.to_string()]) + .output(); + + if check.is_ok() && check.unwrap().status.success() { + log::debug!("PID {pid} still running, sending SIGKILL..."); + let _ = env_sys + .new_command("kill") + .args(["-9", &pid.to_string()]) + .output(); + } + log::debug!("✅ Successfully terminated Jupyter PID {pid}"); killed_any = true; } diff --git a/desktop/src-tauri/src/utils/process_monitor.rs b/desktop/src-tauri/src/utils/process_monitor.rs index ea2eff8beee..0c12bbb17ca 100644 --- a/desktop/src-tauri/src/utils/process_monitor.rs +++ b/desktop/src-tauri/src/utils/process_monitor.rs @@ -77,6 +77,16 @@ pub fn unregister_process(logs: &LogStorage, process_id: &str) -> bool { storage.remove(process_id).is_some() } +pub fn clear_process_logs(logs: &LogStorage, process_id: &str) -> bool { + let mut storage = logs.lock().unwrap(); + if let Some(buffer) = storage.get_mut(process_id) { + buffer.entries.clear(); + true + } else { + false + } +} + #[derive(Deserialize)] pub struct GetProcessLogsRequest { pub process_id: String, @@ -569,6 +579,38 @@ mod tests { assert_eq!(logs[1].content, "Message 5"); } + #[test] + fn test_clear_process_logs_existing() { + let storage = create_log_storage(); + register_process(&storage, "test_process"); + + { + let mut locked = storage.lock().unwrap(); + let buffer = locked.get_mut("test_process").unwrap(); + for i in 1..=3 { + buffer.add(LogEntry { + timestamp: i as i64, + content: format!("Message {i}"), + process_id: "test_process".to_string(), + }); + } + } + + let result = clear_process_logs(&storage, "test_process"); + assert!(result); + + let locked = storage.lock().unwrap(); + let buffer = locked.get("test_process").unwrap(); + assert_eq!(buffer.entries.len(), 0); + } + + #[test] + fn test_clear_process_logs_nonexistent() { + let storage = create_log_storage(); + let result = clear_process_logs(&storage, "nonexistent"); + assert!(!result); + } + #[test] fn test_running_processes_add_process_mock() { let processes = TestRunningProcesses::new(); diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index f32d3b89c79..087c1be3538 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Open Data Platform by OpenBB", - "version": "1.0.1", + "version": "1.0.2", "identifier": "co.openbb.platform", "build": { "frontendDist": "../dist", diff --git a/desktop/src/components/BackendLogsPage.tsx b/desktop/src/components/BackendLogsPage.tsx index ecd50d30a17..643ae070820 100644 --- a/desktop/src/components/BackendLogsPage.tsx +++ b/desktop/src/components/BackendLogsPage.tsx @@ -3,6 +3,7 @@ import { invoke } from '@tauri-apps/api/core'; import { listen } from '@tauri-apps/api/event'; import { useSearch } from '@tanstack/react-router'; import SearchBar from './SearchBar'; +import CustomIcon from './Icon'; import '../styles/jupyter-logs.css'; interface LogEntry { @@ -21,16 +22,17 @@ const BackendLogsPage: React.FC = () => { const [searchVisible, setSearchVisible] = useState(false); const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const [caseSensitive, setCaseSensitive] = useState(false); + const [logsCleared, setLogsCleared] = useState(false); const logContainerRef = useRef(null); const searchInputRef = useRef(null); // Find all matches in the logs const searchMatches = useMemo(() => { if (!searchTerm) return []; - + const matches: { logIndex: number; startIndex: number; endIndex: number }[] = []; const searchRegex = new RegExp( - searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), caseSensitive ? 'g' : 'gi' ); @@ -63,7 +65,7 @@ const BackendLogsPage: React.FC = () => { m => m.logIndex === logIndex && m.startIndex === match.startIndex ); const isCurrentMatch = globalMatchIndex === currentMatchIndex; - + highlightedContent += content.slice(lastIndex, match.startIndex); highlightedContent += `${content.slice(match.startIndex, match.endIndex)}`; lastIndex = match.endIndex; @@ -76,11 +78,11 @@ const BackendLogsPage: React.FC = () => { // Scroll to current match const scrollToMatch = (matchIndex: number) => { if (matchIndex < 0 || matchIndex >= searchMatches.length || !logContainerRef.current) return; - + const match = searchMatches[matchIndex]; const logElements = logContainerRef.current.querySelectorAll('[data-log-index]'); const targetElement = logElements[match.logIndex] as HTMLElement; - + if (targetElement) { requestAnimationFrame(() => { targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); @@ -173,6 +175,7 @@ const BackendLogsPage: React.FC = () => { const unsubscribe = listen<{ processId: string, output: string, timestamp: number }>('process-output', (event) => { const { processId: eventProcessId, output, timestamp } = event.payload; if (eventProcessId === processId) { + setLogsCleared(false); setLogs(prev => ([...prev, { timestamp, content: cleanLogContent(output), process_id: eventProcessId }])); } }); @@ -195,10 +198,21 @@ const BackendLogsPage: React.FC = () => { return (
{!searchVisible && ( -
-
+
+
Press Ctrl+F (Cmd+F) to search
+
{ + const processId = `backend-${backendId}`; + await invoke('clear_process_logs_history', { processId }); + setLogs([]); + setLogsCleared(true); + }} + > + + Clear logs +
)} @@ -218,7 +232,7 @@ const BackendLogsPage: React.FC = () => {
{loading && logs.length === 0 ? ( @@ -227,13 +241,13 @@ const BackendLogsPage: React.FC = () => {
) : error ? (
{error}
- ) : logs.length === 0 ? ( + ) : logs.length === 0 && !logsCleared ? (
No logs available for this backend. Try starting a backend service first.
) : (
{logs.map((log, index) => ( -
{ // Get environment from route parameters const search = useSearch({ from: '/jupyter-logs' }); const environmentName = search.environment as string; - + const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -23,16 +24,17 @@ const JupyterLogsPage: React.FC = () => { const [searchVisible, setSearchVisible] = useState(false); const [currentMatchIndex, setCurrentMatchIndex] = useState(0); const [caseSensitive, setCaseSensitive] = useState(false); + const [logsCleared, setLogsCleared] = useState(false); const logContainerRef = useRef(null); const searchInputRef = useRef(null); // Find all matches in the logs const searchMatches = useMemo(() => { if (!searchTerm) return []; - + const matches: { logIndex: number; startIndex: number; endIndex: number }[] = []; const searchRegex = new RegExp( - searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), + searchTerm.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), caseSensitive ? 'g' : 'gi' ); @@ -65,7 +67,7 @@ const JupyterLogsPage: React.FC = () => { m => m.logIndex === logIndex && m.startIndex === match.startIndex ); const isCurrentMatch = globalMatchIndex === currentMatchIndex; - + highlightedContent += content.slice(lastIndex, match.startIndex); highlightedContent += `${content.slice(match.startIndex, match.endIndex)}`; lastIndex = match.endIndex; @@ -78,11 +80,11 @@ const JupyterLogsPage: React.FC = () => { // Scroll to current match const scrollToMatch = (matchIndex: number) => { if (matchIndex < 0 || matchIndex >= searchMatches.length || !logContainerRef.current) return; - + const match = searchMatches[matchIndex]; const logElements = logContainerRef.current.querySelectorAll('[data-log-index]'); const targetElement = logElements[match.logIndex] as HTMLElement; - + if (targetElement) { requestAnimationFrame(() => { targetElement.scrollIntoView({ behavior: 'smooth', block: 'center' }); @@ -147,7 +149,7 @@ const JupyterLogsPage: React.FC = () => { scrollToMatch(currentMatchIndex); } }, [currentMatchIndex, searchMatches]); - + useEffect(() => { console.log("JupyterLogsPage initialized with environment:", environmentName); @@ -159,34 +161,34 @@ const JupyterLogsPage: React.FC = () => { const processId = `jupyter-${environmentName}`; console.log(`Fetching logs for process: ${processId}`); - + // Register for process monitoring invoke("register_process_monitoring", { processId }) .then(() => console.log(`Process ${processId} registered for monitoring`)) .catch(err => console.error(`Failed to register process monitoring: ${err}`)); - + // Fetch initial logs const fetchInitialLogs = async () => { try { setLoading(true); - + // Get logs for this specific environment/process - const fetchedLogs = await invoke("get_process_logs_history", { - processId + const fetchedLogs = await invoke("get_process_logs_history", { + processId }); - + console.log(`Received ${fetchedLogs?.length || 0} logs for ${environmentName}`); - + if (fetchedLogs && Array.isArray(fetchedLogs)) { setLogs(fetchedLogs.map(log => ({ ...log, content: cleanLogContent(log.content) }))); - + // Check if any logs contain the specific shutdown message checkForShutdownMessage(fetchedLogs); } else { console.warn("No logs returned or invalid format"); setLogs([]); } - + setLoading(false); } catch (err) { console.error(`Failed to fetch logs for ${environmentName}:`, err); @@ -194,20 +196,20 @@ const JupyterLogsPage: React.FC = () => { setLoading(false); } }; - + // Function to check for the specific shutdown message const checkForShutdownMessage = (logEntries: LogEntry[]) => { // Look specifically for the exact shutdown message - const hasShutdownMessage = logEntries.some(log => + const hasShutdownMessage = logEntries.some(log => log.content.includes("Shutting down on /api/shutdown request") ); - + if (hasShutdownMessage) { console.log(`Found API shutdown message for ${environmentName}, notifying parent`); notifyShutdown(); } }; - + // Function to notify parent window about server shutdown const notifyShutdown = () => { try { @@ -220,14 +222,14 @@ const JupyterLogsPage: React.FC = () => { status: 'stopped' }, '*'); } - + // Also use localStorage as a backup communication channel // This helps when direct window communication might fail const shutdownKey = `jupyter-shutdown-${environmentName}`; localStorage.setItem(shutdownKey, Date.now().toString()); - + console.log("Shutdown notification sent via postMessage and localStorage"); - + // If this window was opened by another window, we can close it now // if (window.opener) { // window.close(); @@ -236,15 +238,16 @@ const JupyterLogsPage: React.FC = () => { console.error("Error sending shutdown notification:", err); } }; - + fetchInitialLogs(); - + // Listen for new log entries console.log(`Setting up process-output listener for ${processId}`); const unsubscribe = listen<{ processId: string, output: string, timestamp: number }>('process-output', (event) => { const { processId: eventProcessId, output, timestamp } = event.payload; - + if (eventProcessId === processId) { + setLogsCleared(false); // Add the new log entry setLogs(prev => { const newLogs = [...prev, { @@ -252,25 +255,25 @@ const JupyterLogsPage: React.FC = () => { content: cleanLogContent(output), process_id: eventProcessId }]; - + // Check specifically for the shutdown request message if (output.includes("Shutting down on /api/shutdown request")) { console.log("Detected Jupyter API shutdown request, notifying parent"); notifyShutdown(); } - + return newLogs; }); } }); - + // Cleanup return () => { console.log(`JupyterLogsPage unmounting for ${processId}`); unsubscribe.then(fn => fn()).catch(console.error); }; }, [environmentName]); - + // Auto-scroll to bottom when new logs come in useEffect(() => { if (logContainerRef.current && !searchTerm) { @@ -287,10 +290,21 @@ const JupyterLogsPage: React.FC = () => { return (
{!searchVisible && ( -
-
+
+
Press Ctrl+F (Cmd+F) to search
+
{ + const processId = `jupyter-${environmentName}`; + await invoke('clear_process_logs_history', { processId }); + setLogs([]); + setLogsCleared(true); + }} + > + + Clear logs +
)} @@ -310,7 +324,7 @@ const JupyterLogsPage: React.FC = () => {
{loading && logs.length === 0 ? ( @@ -319,13 +333,13 @@ const JupyterLogsPage: React.FC = () => {
) : error ? (
{error}
- ) : logs.length === 0 ? ( + ) : logs.length === 0 && !logsCleared ? (
No logs available for this environment. Try starting a Jupyter server first.
) : (
{logs.map((log, index) => ( -
).cause = e; + throw err; } } else if (extension === "env") { // Parse .env file diff --git a/desktop/src/styles/jupyter-logs.css b/desktop/src/styles/jupyter-logs.css index a989ae16b84..f964be4885a 100644 --- a/desktop/src/styles/jupyter-logs.css +++ b/desktop/src/styles/jupyter-logs.css @@ -27,7 +27,7 @@ body.jupyter-logs-view main > div { } /* Target any potential horizontal scrollbars */ -body.jupyter-logs-view div, +body.jupyter-logs-view div:not(.logs-toolbar-container):not(.logs-toolbar-container *), body.jupyter-logs-view pre, body.jupyter-logs-view code { overflow-x: hidden !important; /* No horizontal scrolling */ @@ -51,7 +51,7 @@ header.logs-page-header { .search-help-container { position: fixed; - top: 65px; + top: 75px; left: 0; right: 0; z-index: 50; @@ -60,6 +60,17 @@ header.logs-page-header { align-items: flex-start; } +.logs-toolbar-container { + position: fixed; + top: 75px; + left: 0; + right: 0; + z-index: 50; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 18px; +} /* Search highlight styles */ .search-highlight {