From 9ee65cbf077cbb15dffb9e14245eea07bb28c438 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Fri, 5 Jun 2026 11:47:55 +0200 Subject: [PATCH] db_cleanup module --- .env.example | 6 + pytorrent/routes/_shared.py | 14 +- pytorrent/routes/system.py | 11 ++ pytorrent/services/database_maintenance.py | 130 ++++++++++++++++++ .../static/js/backupCleanupRtconfigEvents.js | 2 +- pytorrent/static/js/cleanupTools.js | 2 +- 6 files changed, 158 insertions(+), 7 deletions(-) create mode 100644 pytorrent/services/database_maintenance.py diff --git a/.env.example b/.env.example index 412ad1a..7bf614d 100644 --- a/.env.example +++ b/.env.example @@ -70,3 +70,9 @@ PYTORRENT_SESSION_COOKIE_SECURE=false # bypass auth on specific hosts (ex. local ip) PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11 PYTORRENT_AUTH_BYPASS_USER=admin + +# db vacuum +PYTORRENT_DB_VACUUM_ENABLE=true +PYTORRENT_DB_VACUUM_EVERY_SECONDS=86400 +PYTORRENT_DB_VACUUM_MIN_FREE_MB=512 +PYTORRENT_DB_VACUUM_MIN_FREE_RATIO=0.25 \ No newline at end of file diff --git a/pytorrent/routes/_shared.py b/pytorrent/routes/_shared.py index 7243441..17ed555 100644 --- a/pytorrent/routes/_shared.py +++ b/pytorrent/routes/_shared.py @@ -22,8 +22,8 @@ from flask import Blueprint, jsonify, request, abort, send_file, redirect, Respo # Note: url_for is exported through this shared module for API routes that build temporary in-app links. from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, LOG_RETENTION_DAYS, WORKERS, PYTORRENT_TMP_DIR from ..db import connect, utcnow -from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write -from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control +from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write, require_admin, is_admin +from ..services import preferences, rtorrent, torrent_stats, speed_peaks, tracker_cache, rss as rss_service, ratio_rules, backup as backup_service, download_planner, operation_logs, poller_control, database_maintenance from ..services.torrent_cache import torrent_cache from ..services.torrent_summary import cached_summary from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, force_job, clear_jobs, emergency_clear_jobs @@ -260,10 +260,13 @@ def _table_count(table: str, where: str = "", params: tuple = ()) -> int: def _db_size() -> dict: try: - size = DB_PATH.stat().st_size if DB_PATH.exists() else 0 - return {"path": str(DB_PATH), "size": size, "size_h": rtorrent.human_size(size)} + return database_maintenance.database_status() except Exception as exc: - return {"path": str(DB_PATH), "size": 0, "size_h": "0 B", "error": str(exc)} + try: + size = DB_PATH.stat().st_size if DB_PATH.exists() else 0 + except Exception: + size = 0 + return {"path": str(DB_PATH), "size": size, "size_h": rtorrent.human_size(size), "error": str(exc)} def _active_profile_cache_summary(profile_id: int | None = None) -> dict: @@ -312,6 +315,7 @@ def cleanup_summary() -> dict: "operation_logs": operation_logs.retention_label(operation_log_retention), }, "database": _db_size(), + "admin": is_admin(current_user()), } def active_default_download_path(profile: dict | None) -> str: diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py index b6a2162..49707dc 100644 --- a/pytorrent/routes/system.py +++ b/pytorrent/routes/system.py @@ -203,6 +203,17 @@ def cleanup_jobs(): return ok({"deleted": deleted, "cleanup": cleanup_summary()}) +@bp.post("/cleanup/database/vacuum") +def cleanup_database_vacuum(): + require_admin() + data = request.get_json(silent=True) or {} + try: + result = database_maintenance.vacuum_database(force=bool(data.get("force"))) + return ok({"vacuum": result, "cleanup": cleanup_summary()}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc), "cleanup": cleanup_summary()}), 400 + + @bp.post("/cleanup/smart-queue") def cleanup_smart_queue(): diff --git a/pytorrent/services/database_maintenance.py b/pytorrent/services/database_maintenance.py new file mode 100644 index 0000000..95b108c --- /dev/null +++ b/pytorrent/services/database_maintenance.py @@ -0,0 +1,130 @@ +from __future__ import annotations + +import shutil +import sqlite3 +import threading +import time +from typing import Any + +from ..config import DB_PATH + +_VACUUM_LOCK = threading.Lock() +MIN_DISK_HEADROOM_BYTES = 128 * 1024 * 1024 + + +def _human_size(value: int | float | None) -> str: + size = float(value or 0) + units = ["B", "KiB", "MiB", "GiB", "TiB"] + idx = 0 + while size >= 1024 and idx < len(units) - 1: + size /= 1024.0 + idx += 1 + if idx == 0: + return f"{int(size)} {units[idx]}" + return f"{size:.2f} {units[idx]}" + + +def _connect() -> sqlite3.Connection: + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(DB_PATH, timeout=60, isolation_level=None) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA busy_timeout = 60000") + return conn + + +def _pragma_int(conn: sqlite3.Connection, pragma_name: str) -> int: + row = conn.execute(f"PRAGMA {pragma_name}").fetchone() + if row is None: + return 0 + return int(row[0] or 0) + + +def database_status() -> dict[str, Any]: + size_bytes = DB_PATH.stat().st_size if DB_PATH.exists() else 0 + wal_path = DB_PATH.with_name(DB_PATH.name + "-wal") + wal_bytes = wal_path.stat().st_size if wal_path.exists() else 0 + page_size = 0 + page_count = 0 + freelist_count = 0 + error = None + if DB_PATH.exists(): + try: + with _connect() as conn: + page_size = _pragma_int(conn, "page_size") + page_count = _pragma_int(conn, "page_count") + freelist_count = _pragma_int(conn, "freelist_count") + except Exception as exc: + error = str(exc) + free_bytes = int(page_size * freelist_count) + logical_bytes = int(page_size * page_count) + free_ratio = (free_bytes / logical_bytes) if logical_bytes else 0.0 + try: + disk = shutil.disk_usage(str(DB_PATH.parent)) + disk_free = int(disk.free) + except Exception: + disk_free = 0 + return { + "path": str(DB_PATH), + "size": int(size_bytes), + "size_h": _human_size(size_bytes), + "wal_size": int(wal_bytes), + "wal_size_h": _human_size(wal_bytes), + "page_size": page_size, + "page_count": page_count, + "freelist_count": freelist_count, + "free_inside": free_bytes, + "free_inside_h": _human_size(free_bytes), + "free_ratio": round(free_ratio, 4), + "free_ratio_percent": round(free_ratio * 100, 2), + "disk_free": disk_free, + "disk_free_h": _human_size(disk_free), + "vacuum_running": _VACUUM_LOCK.locked(), + "error": error, + } + + +def _checkpoint_truncate(conn: sqlite3.Connection) -> dict[str, int] | None: + try: + row = conn.execute("PRAGMA wal_checkpoint(TRUNCATE)").fetchone() + if row is None: + return None + return {"busy": int(row[0] or 0), "log": int(row[1] or 0), "checkpointed": int(row[2] or 0)} + except sqlite3.DatabaseError: + return None + + +def vacuum_database(force: bool = False) -> dict[str, Any]: + if not DB_PATH.exists(): + raise FileNotFoundError(f"Database not found: {DB_PATH}") + if not _VACUUM_LOCK.acquire(blocking=False): + raise RuntimeError("Database vacuum is already running") + try: + before = database_status() + required_free = int(before.get("size") or 0) + MIN_DISK_HEADROOM_BYTES + available_free = int(before.get("disk_free") or 0) + if available_free and available_free < required_free: + raise RuntimeError( + "Not enough free disk space for VACUUM: " + f"need about {_human_size(required_free)}, have {_human_size(available_free)}" + ) + if not force and int(before.get("free_inside") or 0) <= 0: + return {"ok": True, "skipped": True, "reason": "No free pages inside SQLite database", "before": before, "after": before} + started = time.perf_counter() + with _connect() as conn: + checkpoint_before = _checkpoint_truncate(conn) + conn.execute("VACUUM") + checkpoint_after = _checkpoint_truncate(conn) + after = database_status() + return { + "ok": True, + "skipped": False, + "duration_seconds": round(time.perf_counter() - started, 3), + "checkpoint_before": checkpoint_before, + "checkpoint_after": checkpoint_after, + "before": before, + "after": after, + "reclaimed": max(0, int(before.get("size") or 0) - int(after.get("size") or 0)), + "reclaimed_h": _human_size(max(0, int(before.get("size") or 0) - int(after.get("size") or 0))), + } + finally: + _VACUUM_LOCK.release() diff --git a/pytorrent/static/js/backupCleanupRtconfigEvents.js b/pytorrent/static/js/backupCleanupRtconfigEvents.js index e7af131..12f4be5 100644 --- a/pytorrent/static/js/backupCleanupRtconfigEvents.js +++ b/pytorrent/static/js/backupCleanupRtconfigEvents.js @@ -1 +1 @@ -export const backupCleanupRtconfigEventsSource = "$('profileBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile',{name:$('profileBackupName')?.value||'Profile backup'}); toast('Profile backup created','success'); loadBackup();}); $('appBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/app',{name:$('appBackupName')?.value||'Application backup'}); toast('Application backup created','success'); loadBackup();}); $('profileBackupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile/settings',{enabled:$('profileBackupAutoEnabled')?.checked,interval_hours:Number($('profileBackupAutoInterval')?.value||24),retention_days:Number($('profileBackupRetentionDays')?.value||30)}); toast('Profile backup schedule saved','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Application backup schedule saved','success'); loadBackup();}); document.querySelectorAll('[data-backup-pane]').forEach(tab=>tab.addEventListener('click',()=>{ if(tab.classList.contains('disabled')) return; switchBackupPane(tab.dataset.backupPane||'profile'); })); const backupClickHandler=async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ const type=restore.dataset.type==='app'?'application':'profile'; const msg=type==='application'?'Restore this application backup and replace users, profiles and global settings?':'Restore this profile backup into the current active profile?'; if(!confirm(msg)) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}; $('profileBackupManager')?.addEventListener('click',backupClickHandler); $('appBackupManager')?.addEventListener('click',backupClickHandler); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); if(e.target.closest('#cleanupPollerDiagnosticsBtn')) return runCleanupAction('/api/cleanup/poller-diagnostics','Reset poller diagnostics'); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); "; +export const backupCleanupRtconfigEventsSource = "$('profileBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile',{name:$('profileBackupName')?.value||'Profile backup'}); toast('Profile backup created','success'); loadBackup();}); $('appBackupCreateBtn')?.addEventListener('click',async()=>{await post('/api/backup/app',{name:$('appBackupName')?.value||'Application backup'}); toast('Application backup created','success'); loadBackup();}); $('profileBackupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/profile/settings',{enabled:$('profileBackupAutoEnabled')?.checked,interval_hours:Number($('profileBackupAutoInterval')?.value||24),retention_days:Number($('profileBackupRetentionDays')?.value||30)}); toast('Profile backup schedule saved','success'); loadBackup();}); $('backupSettingsSaveBtn')?.addEventListener('click',async()=>{await post('/api/backup/settings',{enabled:$('backupAutoEnabled')?.checked,interval_hours:Number($('backupAutoInterval')?.value||24),retention_days:Number($('backupRetentionDays')?.value||30)}); toast('Application backup schedule saved','success'); loadBackup();}); document.querySelectorAll('[data-backup-pane]').forEach(tab=>tab.addEventListener('click',()=>{ if(tab.classList.contains('disabled')) return; switchBackupPane(tab.dataset.backupPane||'profile'); })); const backupClickHandler=async e=>{const preview=e.target.closest('.backup-preview-btn'); const restore=e.target.closest('.backup-restore'); const del=e.target.closest('.backup-delete'); if(preview){ const j=await (await fetch(`/api/backup/${preview.dataset.id}/preview`)).json(); if(!j.ok) throw new Error(j.error||'Backup preview failed'); const box=$('backupPreview'); if(box){ box.classList.remove('d-none'); box.innerHTML=backupPreviewTable(j.preview||{}); box.scrollIntoView({block:'nearest'}); } return; } if(restore){ const type=restore.dataset.type==='app'?'application':'profile'; const msg=type==='application'?'Restore this application backup and replace users, profiles and global settings?':'Restore this profile backup into the current active profile?'; if(!confirm(msg)) return; await post(`/api/backup/${restore.dataset.id}/restore`,{}); toast('Backup restored','success'); loadBackup(); return; } if(del){ if(!confirm('Delete this backup permanently?')) return; await post(`/api/backup/${del.dataset.id}`,{},'DELETE'); toast('Backup deleted','success'); loadBackup(); }}; $('profileBackupManager')?.addEventListener('click',backupClickHandler); $('appBackupManager')?.addEventListener('click',backupClickHandler); $('cleanupManager')?.addEventListener('click',async e=>{ if(e.target.closest('#cleanupRefreshBtn')) return loadCleanup(); if(e.target.closest('#cleanupProfileCacheBtn')) return runCleanupAction('/api/cleanup/cache','Clear active profile cache'); if(e.target.closest('#cleanupPollerDiagnosticsBtn')) return runCleanupAction('/api/cleanup/poller-diagnostics','Reset poller diagnostics'); if(e.target.closest('#cleanupDatabaseVacuumBtn')) return runCleanupAction('/api/cleanup/database/vacuum','Compact SQLite database'); if(e.target.closest('#cleanupJobsBtn')) return runCleanupAction('/api/cleanup/jobs','Clear finished job logs'); if(e.target.closest('#cleanupSmartQueueBtn')) return runCleanupAction('/api/cleanup/smart-queue','Clear Smart Queue logs'); if(e.target.closest('#cleanupOperationLogsBtn')) return runCleanupAction('/api/cleanup/operation-logs','Clear operation logs'); if(e.target.closest('#cleanupPlannerBtn')) return runCleanupAction('/api/cleanup/planner','Clear Planner logs'); if(e.target.closest('#cleanupAutomationsBtn')) return runCleanupAction('/api/cleanup/automations','Clear automation logs'); if(e.target.closest('#cleanupAllBtn')) return runCleanupAction('/api/cleanup/all','Clear job, Smart Queue, operation, Planner and automation logs'); }); $('rtConfigReloadBtn')?.addEventListener('click',loadRtConfig); $('rtConfigResetBtn')?.addEventListener('click',resetRtConfig); $('rtConfigSaveBtn')?.addEventListener('click',saveRtConfig); $('rtConfigGenerateBtn')?.addEventListener('click',generateRtConfig); $('rtConfigManager')?.addEventListener('input',e=>{ if(e.target.classList.contains('rt-config-input')) updateRtConfigDirty(); }); $('rtConfigManager')?.addEventListener('change',e=>{ if(e.target.classList.contains('rt-config-input')){ const label=e.target.closest('.rt-config-switch')?.querySelector('.form-check-label'); if(label) label.textContent=e.target.checked?'On':'Off'; updateRtConfigDirty(); } }); $('rtConfigApplyOnStart')?.addEventListener('change',updateRtConfigDirty); "; diff --git a/pytorrent/static/js/cleanupTools.js b/pytorrent/static/js/cleanupTools.js index 4b6f9f3..83be224 100644 --- a/pytorrent/static/js/cleanupTools.js +++ b/pytorrent/static/js/cleanupTools.js @@ -1 +1 @@ -export const cleanupToolsSource = " function cleanupCountCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? 0)}${note?`${esc(note)}`:''}
`;\n }\n function cleanupRetentionDaysNote(value){ return `retention ${value || '-'} days`; }\n function cleanupOperationLogRetentionNote(data){\n const settings = data.operation_log_retention || {};\n if(data.retention_labels?.operation_logs) return data.retention_labels.operation_logs;\n if(settings.retention_mode === 'lines') return `retention ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'both') return `retention ${settings.retention_days || '-'} days and ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'manual') return 'manual cleanup only';\n return cleanupRetentionDaysNote((data.retention_days || {}).operation_logs);\n }\n function renderCleanup(data={}){\n const box=$('cleanupManager'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cache=data.cache||{};\n const cards=[\n cleanupCountCard('Job logs total', data.jobs_total, cleanupRetentionDaysNote(retention.jobs)),\n cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),\n cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, cleanupRetentionDaysNote(retention.smart_queue_history)),\n cleanupCountCard('Operation logs', data.operation_logs_total, cleanupOperationLogRetentionNote(data)),\n cleanupCountCard('Planner logs', data.planner_history_total, cleanupRetentionDaysNote(retention.planner_history)),\n cleanupCountCard('Automation logs', data.automation_history_total, cleanupRetentionDaysNote(retention.automation_history)),\n cleanupCountCard('Profile cache rows', cache.profile_rows ?? 0, 'tracker + torrent stats cache'),\n cleanupCountCard('Runtime cache', cache.runtime_items ?? 0, 'memory-only profile cache'),\n cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||'')\n ];\n const poller=data.poller_runtime||{};\n const pollerCards=[\n cleanupCountCard('Live poll counter', poller.live_poll_count ?? 0, 'lightweight speed/status loop'),\n cleanupCountCard('List poll counter', poller.list_poll_count ?? 0, 'full snapshot/diff loop'),\n cleanupCountCard('Poller skipped emits', poller.skipped_emissions ?? 0, 'diagnostic counter only')\n ];\n box.innerHTML=`
${cards.join('')}${pollerCards.join('')}
Profile cacheClears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Poller diagnosticsResets in-memory live/list poller counters only. Polling, saved settings and torrent data stay unchanged.
Logs and historyPending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
`;\n }\n async function loadCleanup(){\n const box=$('cleanupManager'); if(!box) return;\n box.innerHTML=' Loading cleanup data...';\n try{\n const j=await (await fetch('/api/cleanup/summary')).json();\n if(!j.ok) throw new Error(j.error||'Cleanup summary failed');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);\n toastMessage('toast.cleanupDone','success',{deleted});\n renderCleanup(j.cleanup||{});\n if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes('/smart-queue') || endpoint.includes('/all')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes('/operation-logs') || endpoint.includes('/all')) loadOperationLogs(true).catch(()=>{});\n if(endpoint.includes('/planner') || endpoint.includes('/all')) loadPlannerPreview().catch(()=>{});\n if(endpoint.includes('/automations') || endpoint.includes('/all')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n"; +export const cleanupToolsSource = " function cleanupCountCard(label, value, note=''){\n return `
${esc(label)}${esc(value ?? 0)}${note?`${esc(note)}`:''}
`;\n }\n function cleanupRetentionDaysNote(value){ return `retention ${value || '-'} days`; }\n function cleanupOperationLogRetentionNote(data){\n const settings = data.operation_log_retention || {};\n if(data.retention_labels?.operation_logs) return data.retention_labels.operation_logs;\n if(settings.retention_mode === 'lines') return `retention ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'both') return `retention ${settings.retention_days || '-'} days and ${settings.retention_lines || '-'} lines`;\n if(settings.retention_mode === 'manual') return 'manual cleanup only';\n return cleanupRetentionDaysNote((data.retention_days || {}).operation_logs);\n }\n function renderCleanup(data={}){\n const box=$('cleanupManager'); if(!box) return;\n const retention=data.retention_days||{};\n const db=data.database||{};\n const cache=data.cache||{};\n const cards=[\n cleanupCountCard('Job logs total', data.jobs_total, cleanupRetentionDaysNote(retention.jobs)),\n cleanupCountCard('Job logs clearable', data.jobs_clearable, 'done / failed / cancelled'),\n cleanupCountCard('Smart Queue logs', data.smart_queue_history_total, cleanupRetentionDaysNote(retention.smart_queue_history)),\n cleanupCountCard('Operation logs', data.operation_logs_total, cleanupOperationLogRetentionNote(data)),\n cleanupCountCard('Planner logs', data.planner_history_total, cleanupRetentionDaysNote(retention.planner_history)),\n cleanupCountCard('Automation logs', data.automation_history_total, cleanupRetentionDaysNote(retention.automation_history)),\n cleanupCountCard('Profile cache rows', cache.profile_rows ?? 0, 'tracker + torrent stats cache'),\n cleanupCountCard('Runtime cache', cache.runtime_items ?? 0, 'memory-only profile cache'),\n cleanupCountCard('Database size', db.size_h||db.size||'-', db.path||''),\n cleanupCountCard('SQLite free inside', db.free_inside_h||'0 B', `${db.free_ratio_percent ?? 0}% reusable`),\n cleanupCountCard('SQLite WAL', db.wal_size_h||'0 B', 'write-ahead log')\n ];\n const poller=data.poller_runtime||{};\n const pollerCards=[\n cleanupCountCard('Live poll counter', poller.live_poll_count ?? 0, 'lightweight speed/status loop'),\n cleanupCountCard('List poll counter', poller.list_poll_count ?? 0, 'full snapshot/diff loop'),\n cleanupCountCard('Poller skipped emits', poller.skipped_emissions ?? 0, 'diagnostic counter only')\n ];\n const adminSection = data.admin ? `
Database compactAdmin-only SQLite VACUUM. Reclaims free pages after retention cleanup and truncates WAL. It can briefly block database writes.
` : '';\n box.innerHTML=`
${cards.join('')}${pollerCards.join('')}
Profile cacheClears only the active profile runtime/DB cache. It does not remove torrents, rules, settings or logs.
Poller diagnosticsResets in-memory live/list poller counters only. Polling, saved settings and torrent data stay unchanged.
${adminSection}
Logs and historyPending and running jobs are preserved. Operation log cleanup removes only profile-scoped log entries.
`;\n }\n async function loadCleanup(){\n const box=$('cleanupManager'); if(!box) return;\n box.innerHTML=' Loading cleanup data...';\n try{\n const j=await (await fetch('/api/cleanup/summary')).json();\n if(!j.ok) throw new Error(j.error||'Cleanup summary failed');\n renderCleanup(j.cleanup||{});\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n async function runCleanupAction(endpoint, label){\n if(!confirm(`${label}?`)) return;\n setBusy(true);\n try{\n const j=await post(endpoint,{});\n const deleted=typeof j.deleted==='object' ? Object.entries(j.deleted).map(([k,v])=>`${k}: ${v}`).join(', ') : String(j.deleted ?? 0);\n toastMessage('toast.cleanupDone','success',{deleted});\n renderCleanup(j.cleanup||{});\n if(endpoint.includes('/jobs')){ jobsPage=0; loadJobs(0).catch(()=>{}); }\n if(endpoint.includes('/smart-queue') || endpoint.includes('/all')) loadSmartQueue().catch(()=>{});\n if(endpoint.includes('/operation-logs') || endpoint.includes('/all')) loadOperationLogs(true).catch(()=>{});\n if(endpoint.includes('/planner') || endpoint.includes('/all')) loadPlannerPreview().catch(()=>{});\n if(endpoint.includes('/automations') || endpoint.includes('/all')) loadAutomations().catch(()=>{});\n }catch(e){ toast(e.message,'danger'); }\n finally{ setBusy(false); }\n }\n";