Skip to content

Commit 196f96e

Browse files
committed
feat: add automatic log cleanup after configurable retention period
- Add `logs.retention_days` setting (default: 30 days, 0 = keep forever) - Add `cleanup_logs_older_than()` to ServerLogManager that walks the entire log directory tree removing rotated files older than the retention period (both server logs and app-level tracing logs) - Run cleanup once on app startup and then every 24 hours via a background task that reads the current retention setting - Add Tauri commands `get_log_retention_days` / `set_log_retention_days` (setting triggers an immediate cleanup with the new value) - Add Auto-Cleanup dropdown to the Logs section in Settings UI with options: 7, 14, 30, 60, 90 days, or keep forever - Add unit tests for cleanup logic (retention=0 skip, old file removal, app tracing log cleanup, empty directory cleanup) - Add settings service test for log_retention_days https://claude.ai/code/session_01V5tgbLyeWrPW5zZ1toRoPZ
1 parent 8628e71 commit 196f96e

8 files changed

Lines changed: 489 additions & 2 deletions

File tree

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

apps/desktop/src-tauri/src/commands/logs.rs

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
//! Tauri commands for server log management
22
33
use crate::state::AppState;
4-
use mcpmux_core::{LogLevel, ServerLog};
4+
use mcpmux_core::{AppSettingsService, LogLevel, ServerLog};
55
use serde::Serialize;
66
use tauri::State;
77
use tracing::{info, warn};
@@ -107,3 +107,33 @@ pub async fn get_server_log_file(
107107

108108
Ok(path.to_string_lossy().to_string())
109109
}
110+
111+
/// Get log retention period in days (0 = keep forever)
112+
#[tauri::command]
113+
pub async fn get_log_retention_days(state: State<'_, AppState>) -> Result<u32, String> {
114+
let settings = AppSettingsService::new(state.settings_repository.clone());
115+
Ok(settings.get_log_retention_days().await)
116+
}
117+
118+
/// Set log retention period in days (0 = keep forever)
119+
#[tauri::command]
120+
pub async fn set_log_retention_days(days: u32, state: State<'_, AppState>) -> Result<(), String> {
121+
info!("[Logs] Setting log retention to {} days", days);
122+
123+
let settings = AppSettingsService::new(state.settings_repository.clone());
124+
settings
125+
.set_log_retention_days(days)
126+
.await
127+
.map_err(|e| format!("Failed to save log retention setting: {}", e))?;
128+
129+
// Run cleanup immediately with the new setting if retention is enabled
130+
if days > 0 {
131+
match state.server_log_manager.cleanup_logs_older_than(days).await {
132+
Ok(n) if n > 0 => info!("[Logs] Cleaned up {} old log file(s)", n),
133+
Ok(_) => {}
134+
Err(e) => warn!("[Logs] Cleanup after setting change failed: {}", e),
135+
}
136+
}
137+
138+
Ok(())
139+
}

apps/desktop/src-tauri/src/lib.rs

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use mcpmux_core::branding;
66
use std::sync::Arc;
77
use tauri::{Emitter, Manager};
88
use tokio::sync::RwLock;
9-
use tracing::{error, info, warn};
9+
use tracing::{debug, error, info, warn};
1010

1111
mod commands;
1212
mod services;
@@ -519,6 +519,53 @@ pub fn run() {
519519
});
520520
}
521521

522+
// Start periodic log cleanup task
523+
{
524+
let log_manager = app_state.server_log_manager.clone();
525+
let settings_repo_for_cleanup = app_state.settings_repository.clone();
526+
527+
tauri::async_runtime::spawn(async move {
528+
use mcpmux_core::AppSettingsService;
529+
530+
let settings = AppSettingsService::new(settings_repo_for_cleanup);
531+
532+
// Run cleanup once at startup
533+
let retention_days = settings.get_log_retention_days().await;
534+
if retention_days > 0 {
535+
info!(
536+
"[LogCleanup] Running startup cleanup (retention: {} days)",
537+
retention_days
538+
);
539+
match log_manager.cleanup_logs_older_than(retention_days).await {
540+
Ok(n) if n > 0 => {
541+
info!("[LogCleanup] Startup cleanup removed {} file(s)", n)
542+
}
543+
Ok(_) => debug!("[LogCleanup] No old log files to clean up"),
544+
Err(e) => warn!("[LogCleanup] Startup cleanup failed: {}", e),
545+
}
546+
}
547+
548+
// Then run every 24 hours
549+
let mut interval =
550+
tokio::time::interval(std::time::Duration::from_secs(24 * 60 * 60));
551+
interval.tick().await; // skip the first immediate tick (already ran above)
552+
553+
loop {
554+
interval.tick().await;
555+
let days = settings.get_log_retention_days().await;
556+
if days > 0 {
557+
match log_manager.cleanup_logs_older_than(days).await {
558+
Ok(n) if n > 0 => {
559+
info!("[LogCleanup] Periodic cleanup removed {} file(s)", n)
560+
}
561+
Ok(_) => {}
562+
Err(e) => warn!("[LogCleanup] Periodic cleanup failed: {}", e),
563+
}
564+
}
565+
}
566+
});
567+
}
568+
522569
// Setup system tray
523570
tray::setup_tray(app.handle())?;
524571

@@ -749,6 +796,8 @@ pub fn run() {
749796
commands::get_server_logs,
750797
commands::clear_server_logs,
751798
commands::get_server_log_file,
799+
commands::get_log_retention_days,
800+
commands::set_log_retention_days,
752801
// App log commands
753802
get_logs_path,
754803
open_logs_folder,

apps/desktop/src/features/settings/SettingsPage.tsx

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
Power,
2222
Minimize2,
2323
XCircle,
24+
Trash2,
2425
} from 'lucide-react';
2526
import { useAppStore, useTheme } from '@/stores';
2627
import { UpdateChecker } from './UpdateChecker';
@@ -47,6 +48,10 @@ export function SettingsPage() {
4748
const [loadingSettings, setLoadingSettings] = useState(true);
4849
const [savingSettings, setSavingSettings] = useState(false);
4950

51+
// Log retention state
52+
const [logRetentionDays, setLogRetentionDays] = useState<number>(30);
53+
const [savingRetention, setSavingRetention] = useState(false);
54+
5055
// Load logs path on mount
5156
useEffect(() => {
5257
const loadLogsPath = async () => {
@@ -60,6 +65,19 @@ export function SettingsPage() {
6065
loadLogsPath();
6166
}, []);
6267

68+
// Load log retention setting on mount
69+
useEffect(() => {
70+
const loadRetention = async () => {
71+
try {
72+
const days = await invoke<number>('get_log_retention_days');
73+
setLogRetentionDays(days);
74+
} catch (err) {
75+
console.error('Failed to load log retention setting:', err);
76+
}
77+
};
78+
loadRetention();
79+
}, []);
80+
6381
// Load startup settings on mount
6482
useEffect(() => {
6583
const loadStartupSettings = async () => {
@@ -109,6 +127,22 @@ export function SettingsPage() {
109127
}
110128
};
111129

130+
const handleRetentionChange = async (days: number) => {
131+
const oldDays = logRetentionDays;
132+
setLogRetentionDays(days);
133+
setSavingRetention(true);
134+
try {
135+
await invoke('set_log_retention_days', { days });
136+
success('Settings saved', `Log retention set to ${days === 0 ? 'keep forever' : `${days} days`}`);
137+
} catch (err) {
138+
const errorMessage = err instanceof Error ? err.message : 'Unknown error';
139+
error('Failed to save setting', errorMessage);
140+
setLogRetentionDays(oldDays);
141+
} finally {
142+
setSavingRetention(false);
143+
}
144+
};
145+
112146
const handleOpenLogs = async () => {
113147
setOpeningLogs(true);
114148
try {
@@ -301,6 +335,33 @@ export function SettingsPage() {
301335
Open Logs Folder
302336
</Button>
303337
</div>
338+
<div className="border-t border-[rgb(var(--border))] pt-4">
339+
<div className="flex items-center justify-between gap-4">
340+
<div className="flex items-start gap-3 flex-1 min-w-0">
341+
<Trash2 className="h-5 w-5 mt-0.5 text-[rgb(var(--muted))] flex-shrink-0" />
342+
<div>
343+
<label className="text-sm font-medium">Auto-Cleanup</label>
344+
<p className="text-xs text-[rgb(var(--muted))] mt-1">
345+
Automatically delete log files older than the selected period
346+
</p>
347+
</div>
348+
</div>
349+
<select
350+
value={logRetentionDays}
351+
onChange={(e) => handleRetentionChange(Number(e.target.value))}
352+
disabled={savingRetention}
353+
className="px-3 py-1.5 text-sm border border-[rgb(var(--border))] rounded-lg bg-[rgb(var(--surface))] text-[rgb(var(--foreground))]"
354+
data-testid="log-retention-select"
355+
>
356+
<option value={7}>7 days</option>
357+
<option value={14}>14 days</option>
358+
<option value={30}>30 days</option>
359+
<option value={60}>60 days</option>
360+
<option value={90}>90 days</option>
361+
<option value={0}>Keep forever</option>
362+
</select>
363+
</div>
364+
</div>
304365
<p className="text-xs text-[rgb(var(--muted))]">
305366
Logs are rotated daily. Each file contains detailed debug information including thread IDs and source locations.
306367
</p>

apps/desktop/src/lib/api/logs.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,3 +40,18 @@ export async function getServerLogFile(serverId: string): Promise<string> {
4040
return invoke('get_server_log_file', { serverId });
4141
}
4242

43+
/**
44+
* Get log retention period in days (0 = keep forever).
45+
*/
46+
export async function getLogRetentionDays(): Promise<number> {
47+
return invoke('get_log_retention_days');
48+
}
49+
50+
/**
51+
* Set log retention period in days (0 = keep forever).
52+
* Triggers an immediate cleanup with the new setting.
53+
*/
54+
export async function setLogRetentionDays(days: number): Promise<void> {
55+
return invoke('set_log_retention_days', { days });
56+
}
57+

crates/mcpmux-core/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,4 +26,5 @@ lazy_static = "1.5"
2626
[dev-dependencies]
2727
tokio = { workspace = true, features = ["test-util", "macros"] }
2828
tempfile = "3.14"
29+
filetime = "0.2"
2930

crates/mcpmux-core/src/service/app_settings_service.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,12 @@ pub mod keys {
3838
pub const WINDOW_STATE: &str = "ui.window_state";
3939
}
4040

41+
/// Logs settings namespace
42+
pub mod logs {
43+
/// Number of days to retain log files (u32, 0 = keep forever)
44+
pub const RETENTION_DAYS: &str = "logs.retention_days";
45+
}
46+
4147
/// Registry settings namespace
4248
pub mod registry {
4349
/// Cached ETag from last bundle fetch
@@ -236,6 +242,28 @@ impl AppSettingsService {
236242
self.repository.set(keys::ui::WINDOW_STATE, &json).await
237243
}
238244

245+
// =========================================================================
246+
// Logs settings
247+
// =========================================================================
248+
249+
/// Default log retention period in days (30 days)
250+
pub const DEFAULT_LOG_RETENTION_DAYS: u32 = 30;
251+
252+
/// Get the log retention period in days (0 = keep forever).
253+
pub async fn get_log_retention_days(&self) -> u32 {
254+
self.get_typed(keys::logs::RETENTION_DAYS)
255+
.await
256+
.unwrap_or(Self::DEFAULT_LOG_RETENTION_DAYS)
257+
}
258+
259+
/// Set the log retention period in days.
260+
pub async fn set_log_retention_days(&self, days: u32) -> anyhow::Result<()> {
261+
info!("[Settings] Setting log retention to {} days", days);
262+
self.repository
263+
.set(keys::logs::RETENTION_DAYS, &days.to_string())
264+
.await
265+
}
266+
239267
// =========================================================================
240268
// Utility methods
241269
// =========================================================================
@@ -380,4 +408,21 @@ mod tests {
380408
let loaded: WindowState = service.get_window_state().await;
381409
assert_eq!(loaded, state);
382410
}
411+
412+
#[tokio::test]
413+
async fn test_log_retention_days() {
414+
let repo = Arc::new(InMemorySettingsRepository::new());
415+
let service = AppSettingsService::new(repo);
416+
417+
// Default is 30 days
418+
assert_eq!(service.get_log_retention_days().await, 30);
419+
420+
// Set to 7 days
421+
service.set_log_retention_days(7).await.unwrap();
422+
assert_eq!(service.get_log_retention_days().await, 7);
423+
424+
// Set to 0 (keep forever)
425+
service.set_log_retention_days(0).await.unwrap();
426+
assert_eq!(service.get_log_retention_days().await, 0);
427+
}
383428
}

0 commit comments

Comments
 (0)