add clear logs button to logs windows (#7429)

This commit is contained in:
Danglewood
2026-04-18 22:31:45 -07:00
committed by GitHub
parent 207c24a466
commit 203f62e7d2
8 changed files with 164 additions and 57 deletions

View File

@@ -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<ProcessLogState>, 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,

View File

@@ -391,14 +391,31 @@ pub async fn stop_jupyter_server_impl<R: tauri::Runtime, E: EnvSystem>(
if let Ok(pid) = pid_str.parse::<u32>() {
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;
}

View File

@@ -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();

View File

@@ -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",

View File

@@ -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<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(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 += `<span class="${isCurrentMatch ? 'search-highlight-current' : 'search-highlight'}">${content.slice(match.startIndex, match.endIndex)}</span>`;
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 (
<div className="h-full w-full flex flex-col bg-theme-secondary relative flex-grow">
{!searchVisible && (
<div className="search-help-container opacity-0 hover:opacity-100 transition-opacity hover:cursor-default">
<div className="body-xs-regular text-theme-muted items-center justify-center">
<div className="logs-toolbar-container opacity-0 hover:opacity-100 transition-opacity">
<div className="body-xs-regular text-theme-muted hover:cursor-default">
Press Ctrl+F (Cmd+F) to search
</div>
<div className="flex items-center gap-1 hover:cursor-pointer"
onClick={async () => {
const processId = `backend-${backendId}`;
await invoke('clear_process_logs_history', { processId });
setLogs([]);
setLogsCleared(true);
}}
>
<CustomIcon id="delete" className="h-3.5 w-3.5 text-theme-muted" />
<span className="body-xs-regular text-theme-muted">Clear logs</span>
</div>
</div>
)}
@@ -218,7 +232,7 @@ const BackendLogsPage: React.FC = () => {
<div className="jupyter-logs-content-section flex-grow flex flex-col">
<div
ref={logContainerRef}
ref={logContainerRef}
className="jupyter-logs-content w-full flex-grow overflow-auto bg-theme-secondary font-mono text-xs whitespace-pre-wrap"
>
{loading && logs.length === 0 ? (
@@ -227,13 +241,13 @@ const BackendLogsPage: React.FC = () => {
</div>
) : error ? (
<div className="text-red-500 py-2 px-2">{error}</div>
) : logs.length === 0 ? (
) : logs.length === 0 && !logsCleared ? (
<div className="text-theme-secondary">No logs available for this backend. Try starting a backend service first.</div>
) : (
<div>
{logs.map((log, index) => (
<div
key={index}
<div
key={index}
className="py-0.5"
data-log-index={index}
dangerouslySetInnerHTML={{

View File

@@ -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 {
@@ -15,7 +16,7 @@ const JupyterLogsPage: React.FC = () => {
// Get environment from route parameters
const search = useSearch({ from: '/jupyter-logs' });
const environmentName = search.environment as string;
const [logs, setLogs] = useState<LogEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<HTMLDivElement>(null);
const searchInputRef = useRef<HTMLInputElement>(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 += `<span class="${isCurrentMatch ? 'search-highlight-current' : 'search-highlight'}">${content.slice(match.startIndex, match.endIndex)}</span>`;
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<LogEntry[]>("get_process_logs_history", {
processId
const fetchedLogs = await invoke<LogEntry[]>("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 (
<div className="h-full w-full flex flex-col bg-theme-secondary relative flex-grow">
{!searchVisible && (
<div className="search-help-container opacity-0 hover:opacity-100 transition-opacity hover:cursor-default">
<div className="body-xs-regular text-theme-muted items-center justify-center">
<div className="logs-toolbar-container opacity-0 hover:opacity-100 transition-opacity">
<div className="body-xs-regular text-theme-muted hover:cursor-default">
Press Ctrl+F (Cmd+F) to search
</div>
<div className="flex items-center gap-1 hover:cursor-pointer"
onClick={async () => {
const processId = `jupyter-${environmentName}`;
await invoke('clear_process_logs_history', { processId });
setLogs([]);
setLogsCleared(true);
}}
>
<CustomIcon id="delete" className="h-3.5 w-3.5 text-theme-muted" />
<span className="body-xs-regular text-theme-muted">Clear logs</span>
</div>
</div>
)}
@@ -310,7 +324,7 @@ const JupyterLogsPage: React.FC = () => {
<div className="jupyter-logs-content-section flex-grow flex flex-col">
<div
ref={logContainerRef}
ref={logContainerRef}
className="jupyter-logs-content w-full flex-grow overflow-auto bg-theme-secondary font-mono text-xs whitespace-pre-wrap"
>
{loading && logs.length === 0 ? (
@@ -319,13 +333,13 @@ const JupyterLogsPage: React.FC = () => {
</div>
) : error ? (
<div className="text-red-500 py-2 px-2">{error}</div>
) : logs.length === 0 ? (
) : logs.length === 0 && !logsCleared ? (
<div className="text-theme-secondary">No logs available for this environment. Try starting a Jupyter server first.</div>
) : (
<div>
{logs.map((log, index) => (
<div
key={index}
<div
key={index}
className="py-0.5"
data-log-index={index}
dangerouslySetInnerHTML={{

View File

@@ -89,7 +89,9 @@ export default function ApiKeysPage() {
});
}
} catch (e) {
throw new Error(`Invalid JSON file: ${e}`);
const err = new Error(`Invalid JSON file: ${e}`);
(err as unknown as Record<string, unknown>).cause = e;
throw err;
}
} else if (extension === "env") {
// Parse .env file

View File

@@ -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 {