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..19afb97 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 compactReclaims 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 if(j.vacuum){\n const vacuum=j.vacuum||{};\n if(vacuum.skipped){\n toast(`Database compact skipped: ${vacuum.reason || 'nothing to reclaim'}`,'warning');\n } else {\n toast(`Database compact done. Reclaimed ${vacuum.reclaimed_h || '0 B'}.`,'success');\n }\n } else {\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 }\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"; diff --git a/pytorrent/static/js/mobileTorrentDetails.js b/pytorrent/static/js/mobileTorrentDetails.js index 87410a1..f046fd4 100644 --- a/pytorrent/static/js/mobileTorrentDetails.js +++ b/pytorrent/static/js/mobileTorrentDetails.js @@ -1 +1 @@ -export const mobileTorrentDetailsSource = " function mobileDetailValue(value, fallback='-'){\n const text = value === null || value === undefined || value === '' ? fallback : String(value);\n return esc(text);\n }\n function mobileDetailsStatCards(t){\n const stats = [\n ['Status', t.status || '-'],\n ['Progress', `${Number(t.progress || 0)}%`],\n ['Size', t.size_h || '-'],\n ['Downloaded', t.down_total_h || '-'],\n ['Uploaded', t.up_total_h || '-'],\n ['DL / UL', `${t.down_rate_h || '-'} / ${t.up_rate_h || '-'}`],\n ['Seeds / Peers', `${t.seeds ?? 0} / ${t.peers ?? 0}`],\n ['Ratio', t.ratio ?? '-'],\n ['ETA', t.eta_h || '-'],\n ['Added', formatDateTime(t.created)],\n ];\n return stats.map(([label,value]) => `
${esc(label)}${mobileDetailValue(value)}
`).join('');\n }\n function mobileDetailsPeerRows(peers){\n // Note: Mobile peers use the same responsive table wrapper as desktop details for consistent spacing and scrolling.\n return (peers || []).slice(0, 40).map(p => {\n const location = [p.country, p.city].filter(Boolean).join(', ') || '-';\n const ip = `${esc(p.ip || '-')}`;\n const row = [flag(p.country_iso), ip];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(location), esc(p.client || '-'), progressBar(p.completed || 0, 'peer-progress peer-progress-wide'), esc(p.down_rate_h || '-'), esc(p.up_rate_h || '-'), esc(p.port || '-'), peerBadges(p));\n return row;\n });\n }\n function scheduleMobileReverseDnsRefresh(hash, torrent, payload){\n // Note: Mobile details refresh only the peers payload while preserving already-loaded files and trackers.\n clearTimeout(mobileReverseDnsRefreshTimer);\n if(!hasPendingReverseDns(payload.peers?.value?.peers || [])){ mobileReverseDnsRefreshAttempts=0; return; }\n if(!hash || mobileReverseDnsRefreshAttempts>=REVERSE_DNS_REFRESH_MAX_ATTEMPTS) return;\n mobileReverseDnsRefreshAttempts+=1;\n mobileReverseDnsRefreshTimer=setTimeout(async()=>{\n if(selectedHash!==hash || !$('mobileDetailsModal')?.classList.contains('show')) return;\n try{\n const peersJson=await fetchMobileDetailsJson(hash, 'peers');\n const nextPayload={...payload, peers:{status:'fulfilled', value:peersJson}};\n const body=$('mobileDetailsBody');\n if(body && selectedHash===hash){\n body.innerHTML=renderMobileDetailsContent(torrent, nextPayload);\n scheduleMobileReverseDnsRefresh(hash, torrent, nextPayload);\n }\n }catch(_){ }\n }, REVERSE_DNS_REFRESH_SECONDS*1000);\n }\n function mobileDetailsPeerTable(peers){\n const headers = ['Flag', 'IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Location', 'Client', '%', 'DL', 'UL', 'Port', 'Flags');\n const rows = mobileDetailsPeerRows(peers);\n if(!rows.length) return '
No peers returned by rTorrent.
';\n return responsiveTable(headers, rows, reverseDnsEnabled ? 'peers-table mobile-details-peers-table peers-table-hosts' : 'peers-table mobile-details-peers-table');\n }\n function mobileDetailsFileTable(files){\n const rows = (files || []).map(file => {\n const priority = FILE_PRIORITY_LABELS[Number(file.priority || 0)] || file.priority || '-';\n const actions = `
${renderFileInfoButton(file)}
`;\n return [\n `${esc(file.path || file.name || '-')}`,\n esc(file.size_h || '-'),\n progressBar(file.progress ?? 0, 'file-progress'),\n `${esc(priority)}`,\n renderFilePrioritySelect(file),\n actions,\n ];\n });\n // Note: Mobile files now reuse the same compact table pattern as peers, with per-file priority, state, info and download actions.\n if(!rows.length) return '
No files returned by rTorrent.
';\n return responsiveTable(['Path', 'Size', 'Done', 'Priority', 'Set priority', 'Actions'], rows, 'file-priority-table mobile-details-files-table');\n }\n function mobileDetailsTrackerItem(t){\n return `
  • ${esc(t.url || '-')}Seeds / Peers: ${esc(trackerSeedsPeers(t))}
  • `;\n }\n function mobileDetailsSection(title, icon, body, meta='', options={}){\n const collapsed = !!options.collapsed;\n const titleMarkup = `
    ${esc(title)}${meta ? `${esc(meta)}` : ''}
    `;\n if(collapsed){\n // Note: Heavy mobile sections start collapsed to keep torrent details quick to scan on phones.\n return `
    ${titleMarkup}${body}
    `;\n }\n return `
    ${titleMarkup}${body}
    `;\n }\n function ensureMobileDetailsModal(){\n let modal = $('mobileDetailsModal');\n if(modal) return modal;\n // Note: Mobile torrent details are lazy-created so the desktop details pane and existing tabs stay unchanged.\n modal = document.createElement('div');\n modal.id = 'mobileDetailsModal';\n modal.className = 'modal fade mobile-details-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    Torrent details
    Loading torrent details...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function renderMobileDetailsContent(t, payload){\n const peers = payload.peers?.status === 'fulfilled' ? (payload.peers.value.peers || []) : [];\n const files = payload.files?.status === 'fulfilled' ? (payload.files.value.files || []) : [];\n const trackers = payload.trackers?.status === 'fulfilled' ? (payload.trackers.value.trackers || []) : [];\n const failures = ['peers','files','trackers'].filter(key => payload[key]?.status === 'rejected').map(key => `${key}: ${payload[key].reason?.message || 'failed'}`);\n const fullPath = joinRemotePath(t.path, t.name);\n const peerTable = mobileDetailsPeerTable(peers);\n const fileTable = mobileDetailsFileTable(files);\n const trackerList = trackers.slice(0, 12).map(mobileDetailsTrackerItem).join('') || '
  • No trackers returned by rTorrent.
  • ';\n const generalBody = `
    ${esc(t.name || '-')}
    Path${esc(fullPath)}
    Hash${esc(t.hash || '-')}
    ${mobileDetailsStatCards(t)}
    `;\n const messageBody = `
    ${esc(t.message || 'No message.')}
    `;\n const errorBox = failures.length ? `
    Partial details loaded
    ${esc(failures.join(' | '))}
    ` : '';\n // Note: General and heavy lists start collapsed on mobile so the modal opens cleanly and the user expands only the section needed.\n return `${errorBox}${mobileDetailsSection('General', 'fa-circle-info', generalBody, '', {collapsed:true})}${mobileDetailsSection('Peers', 'fa-users', peerTable, peers.length > 40 ? `showing 40 of ${peers.length}` : `${peers.length} total`, {collapsed:true})}${mobileDetailsSection('Files', 'fa-folder-tree', fileTable, files.length ? `${files.length} total` : '', {collapsed:true})}${mobileDetailsSection('Trackers', 'fa-bullhorn', ``, trackers.length > 12 ? `showing 12 of ${trackers.length}` : `${trackers.length} total`, {collapsed:true})}${mobileDetailsSection('Message', 'fa-message', messageBody, '', {collapsed:true})}`;\n }\n async function fetchMobileDetailsJson(hash, tab){\n const res = await fetch(`/api/torrents/${encodeURIComponent(hash)}/${tab}`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n return json;\n }\n async function openMobileDetails(hash){\n const t = torrents.get(hash);\n if(!t) return toast('Torrent is no longer available.','warning');\n selectedHash = hash;\n lastSelectedHash = hash;\n const modal = ensureMobileDetailsModal();\n const title = $('mobileDetailsTitle');\n const subtitle = $('mobileDetailsSubtitle');\n const body = $('mobileDetailsBody');\n if(title) title.innerHTML = ' Torrent details';\n if(subtitle) subtitle.textContent = t.name || hash;\n if(body) body.innerHTML = '
    Loading peers, files and trackers...
    ';\n new bootstrap.Modal(modal).show();\n try{\n // Note: The mobile modal reads existing lightweight detail endpoints without changing the desktop details tabs.\n const [peers, files, trackers] = await Promise.allSettled([\n fetchMobileDetailsJson(hash, 'peers'),\n fetchMobileDetailsJson(hash, 'files'),\n fetchMobileDetailsJson(hash, 'trackers'),\n ]);\n if(body) body.innerHTML = renderMobileDetailsContent(t, {peers, files, trackers});\n mobileReverseDnsRefreshAttempts=0;\n scheduleMobileReverseDnsRefresh(hash, t, {peers, files, trackers});\n }catch(e){\n if(body) body.innerHTML = `
    Details failed
    ${esc(e.message)}
    `;\n }\n }\n\n"; +export const mobileTorrentDetailsSource = " function mobileDetailValue(value, fallback='-'){\n const text = value === null || value === undefined || value === '' ? fallback : String(value);\n return esc(text);\n }\n function mobileDetailsStatCards(t){\n const stats = [\n ['Status', t.status || '-'],\n ['Progress', `${Number(t.progress || 0)}%`],\n ['Size', t.size_h || '-'],\n ['Downloaded', t.down_total_h || '-'],\n ['Uploaded', t.up_total_h || '-'],\n ['DL / UL', `${t.down_rate_h || '-'} / ${t.up_rate_h || '-'}`],\n ['Seeds / Peers', `${t.seeds ?? 0} / ${t.peers ?? 0}`],\n ['Ratio', t.ratio ?? '-'],\n ['ETA', t.eta_h || '-'],\n ['Added', formatDateTime(t.created)],\n ];\n return stats.map(([label,value]) => `
    ${esc(label)}${mobileDetailValue(value)}
    `).join('');\n }\n function mobileDetailsPeerRows(peers){\n // Note: Mobile peers use the same responsive table wrapper as desktop details for consistent spacing and scrolling.\n return (peers || []).slice(0, 40).map(p => {\n const location = [p.country, p.city].filter(Boolean).join(', ') || '-';\n const ip = `${esc(p.ip || '-')}`;\n const row = [flag(p.country_iso), ip];\n if(reverseDnsEnabled) row.push(peerHostCell(p));\n row.push(esc(location), esc(p.client || '-'), progressBar(p.completed || 0, 'peer-progress peer-progress-wide'), esc(p.down_rate_h || '-'), esc(p.up_rate_h || '-'), esc(p.port || '-'), peerBadges(p));\n return row;\n });\n }\n function scheduleMobileReverseDnsRefresh(hash, torrent, payload){\n // Note: Mobile details refresh only the peers payload while preserving already-loaded files and trackers.\n clearTimeout(mobileReverseDnsRefreshTimer);\n if(!hasPendingReverseDns(payload.peers?.value?.peers || [])){ mobileReverseDnsRefreshAttempts=0; return; }\n if(!hash || mobileReverseDnsRefreshAttempts>=REVERSE_DNS_REFRESH_MAX_ATTEMPTS) return;\n mobileReverseDnsRefreshAttempts+=1;\n mobileReverseDnsRefreshTimer=setTimeout(async()=>{\n if(selectedHash!==hash || !$('mobileDetailsModal')?.classList.contains('show')) return;\n try{\n const peersJson=await fetchMobileDetailsJson(hash, 'peers');\n const nextPayload={...payload, peers:{status:'fulfilled', value:peersJson}};\n const body=$('mobileDetailsBody');\n if(body && selectedHash===hash){\n body.innerHTML=renderMobileDetailsContent(torrent, nextPayload);\n scheduleMobileReverseDnsRefresh(hash, torrent, nextPayload);\n }\n }catch(_){ }\n }, REVERSE_DNS_REFRESH_SECONDS*1000);\n }\n function mobileDetailsPeerTable(peers){\n const headers = ['Flag', 'IP'];\n if(reverseDnsEnabled) headers.push('Host');\n headers.push('Location', 'Client', '%', 'DL', 'UL', 'Port', 'Flags');\n const rows = mobileDetailsPeerRows(peers);\n if(!rows.length) return '
    No peers returned by rTorrent.
    ';\n return responsiveTable(headers, rows, reverseDnsEnabled ? 'peers-table mobile-details-peers-table peers-table-hosts' : 'peers-table mobile-details-peers-table');\n }\n function mobileDetailsFileTable(files){\n const rows = (files || []).map(file => {\n const priority = FILE_PRIORITY_LABELS[Number(file.priority || 0)] || file.priority || '-';\n const actions = `
    ${renderFileInfoButton(file)}
    `;\n return [\n `${esc(file.path || file.name || '-')}`,\n esc(file.size_h || '-'),\n progressBar(file.progress ?? 0, 'file-progress'),\n `${esc(priority)}`,\n renderFilePrioritySelect(file),\n actions,\n ];\n });\n // Note: Mobile files now reuse the same compact table pattern as peers, with per-file priority, state, info and download actions.\n if(!rows.length) return '
    No files returned by rTorrent.
    ';\n return responsiveTable(['Path', 'Size', 'Done', 'Priority', 'Set priority', 'Actions'], rows, 'file-priority-table mobile-details-files-table');\n }\n function mobileDetailsTrackerRows(trackers){\n // Note: Mobile trackers mirror the desktop tracker table instead of using a separate list layout.\n return (trackers || []).slice(0, 12).map(t => [\n `#${esc(t.index)}`,\n trackerUrlCell(t),\n trackerEnabledCell(t.enabled),\n esc(trackerSeedsPeers(t)),\n esc(t.downloaded ?? '-'),\n fmtTs(t.last_announce),\n ]);\n }\n function mobileDetailsTrackerTable(trackers){\n const rows = mobileDetailsTrackerRows(trackers);\n if(!rows.length) return '
    No trackers returned by rTorrent.
    ';\n return responsiveTable(['#', 'URL', 'On', 'Seeds / Peers', 'Done', 'Last announce'], rows, 'tracker-table mobile-details-trackers-table');\n }\n function mobileDetailsSection(title, icon, body, meta='', options={}){\n const collapsed = !!options.collapsed;\n const titleMarkup = `
    ${esc(title)}${meta ? `${esc(meta)}` : ''}
    `;\n if(collapsed){\n // Note: Heavy mobile sections start collapsed to keep torrent details quick to scan on phones.\n return `
    ${titleMarkup}${body}
    `;\n }\n return `
    ${titleMarkup}${body}
    `;\n }\n function ensureMobileDetailsModal(){\n let modal = $('mobileDetailsModal');\n if(modal) return modal;\n // Note: Mobile torrent details are lazy-created so the desktop details pane and existing tabs stay unchanged.\n modal = document.createElement('div');\n modal.id = 'mobileDetailsModal';\n modal.className = 'modal fade mobile-details-modal';\n modal.tabIndex = -1;\n modal.innerHTML = `
    Torrent details
    Loading torrent details...
    `;\n document.body.appendChild(modal);\n return modal;\n }\n function renderMobileDetailsContent(t, payload){\n const peers = payload.peers?.status === 'fulfilled' ? (payload.peers.value.peers || []) : [];\n const files = payload.files?.status === 'fulfilled' ? (payload.files.value.files || []) : [];\n const trackers = payload.trackers?.status === 'fulfilled' ? (payload.trackers.value.trackers || []) : [];\n const failures = ['peers','files','trackers'].filter(key => payload[key]?.status === 'rejected').map(key => `${key}: ${payload[key].reason?.message || 'failed'}`);\n const fullPath = joinRemotePath(t.path, t.name);\n const peerTable = mobileDetailsPeerTable(peers);\n const fileTable = mobileDetailsFileTable(files);\n const trackerTable = mobileDetailsTrackerTable(trackers);\n const generalBody = `
    ${esc(t.name || '-')}
    Path${esc(fullPath)}
    Hash${esc(t.hash || '-')}
    ${mobileDetailsStatCards(t)}
    `;\n const messageBody = `
    ${esc(t.message || 'No message.')}
    `;\n const errorBox = failures.length ? `
    Partial details loaded
    ${esc(failures.join(' | '))}
    ` : '';\n // Note: General and heavy lists start collapsed on mobile so the modal opens cleanly and the user expands only the section needed.\n return `${errorBox}${mobileDetailsSection('General', 'fa-circle-info', generalBody, '', {collapsed:true})}${mobileDetailsSection('Peers', 'fa-users', peerTable, peers.length > 40 ? `showing 40 of ${peers.length}` : `${peers.length} total`, {collapsed:true})}${mobileDetailsSection('Files', 'fa-folder-tree', fileTable, files.length ? `${files.length} total` : '', {collapsed:true})}${mobileDetailsSection('Trackers', 'fa-bullhorn', trackerTable, trackers.length > 12 ? `showing 12 of ${trackers.length}` : `${trackers.length} total`, {collapsed:true})}${mobileDetailsSection('Message', 'fa-message', messageBody, '', {collapsed:true})}`;\n }\n async function fetchMobileDetailsJson(hash, tab){\n const res = await fetch(`/api/torrents/${encodeURIComponent(hash)}/${tab}`, {headers:{'Accept':'application/json'}});\n const json = await res.json().catch(() => ({}));\n if(!res.ok || !json.ok) throw new Error(json.error || `HTTP ${res.status}`);\n return json;\n }\n async function openMobileDetails(hash){\n const t = torrents.get(hash);\n if(!t) return toast('Torrent is no longer available.','warning');\n selectedHash = hash;\n lastSelectedHash = hash;\n const modal = ensureMobileDetailsModal();\n const title = $('mobileDetailsTitle');\n const subtitle = $('mobileDetailsSubtitle');\n const body = $('mobileDetailsBody');\n if(title) title.innerHTML = ' Torrent details';\n if(subtitle) subtitle.textContent = t.name || hash;\n if(body) body.innerHTML = '
    Loading peers, files and trackers...
    ';\n new bootstrap.Modal(modal).show();\n try{\n // Note: The mobile modal reads existing lightweight detail endpoints without changing the desktop details tabs.\n const [peers, files, trackers] = await Promise.allSettled([\n fetchMobileDetailsJson(hash, 'peers'),\n fetchMobileDetailsJson(hash, 'files'),\n fetchMobileDetailsJson(hash, 'trackers'),\n ]);\n if(body) body.innerHTML = renderMobileDetailsContent(t, {peers, files, trackers});\n mobileReverseDnsRefreshAttempts=0;\n scheduleMobileReverseDnsRefresh(hash, t, {peers, files, trackers});\n }catch(e){\n if(body) body.innerHTML = `
    Details failed
    ${esc(e.message)}
    `;\n }\n }\n\n"; diff --git a/pytorrent/static/js/torrentTrackerDetails.js b/pytorrent/static/js/torrentTrackerDetails.js index f93ca1c..ffae31b 100644 --- a/pytorrent/static/js/torrentTrackerDetails.js +++ b/pytorrent/static/js/torrentTrackerDetails.js @@ -1 +1 @@ -export const torrentTrackerDetailsSource = " function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index), url=esc(t.url);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx}`, `${url || '-'}`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
    `];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`
    ${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n"; +export const torrentTrackerDetailsSource = " function fmtTs(value){ const n=Number(value||0); if(!n) return '-'; try{return new Date(n*1000).toLocaleString();}catch(e){return String(n);} }\n function trackerSeedsPeers(t){ const hasScrape = t.seeds !== null || t.peers !== null; return hasScrape ? `${t.seeds ?? \"-\"} / ${t.peers ?? \"-\"}` : \"-\"; }\n function trackerUrlCell(t){\n const url=String(t.url||'').trim();\n // Note: Tracker URLs now use the same single-line ellipsis behavior as peer table cells.\n return url ? `${esc(url)}` : '-';\n }\n function trackerEnabledCell(enabled){\n // Note: Tracker enabled state is rendered as a compact badge to keep row height aligned with peers.\n return enabled ? 'yes' : 'no';\n }\n function renderTrackers(trackers){\n // Note: Tracker URL editing is intentionally replaced by safe deletion; adding trackers remains unchanged.\n const pane=$('detailPane');\n const list=trackers||[];\n const canDelete=list.length>1;\n const rows=list.map(t=>{\n const idx=esc(t.index);\n const deleteDisabled=canDelete ? '' : ' disabled title=\"At least one tracker must remain\"';\n return [`#${idx}`, trackerUrlCell(t), trackerEnabledCell(t.enabled), esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `
    `];\n });\n // Note: Trackers now use the same fixed responsive table pattern as peers for consistent row text and spacing.\n pane.innerHTML=`
    ${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '-','No trackers.','','','','','' ]], 'tracker-table')}`;\n }\n async function trackerAction(action,payload={}){\n if(!selectedHash) return toastMessage('toast.noTorrentSelected','warning');\n setBusy(true);\n try{\n const j=await post(`/api/torrents/${encodeURIComponent(selectedHash)}/trackers/${action}`,payload);\n toast(j.message || appMessage('toast.trackerActionDone',{action}),'success');\n await loadDetails('trackers');\n }catch(e){toast(e.message,'danger');}\n finally{setBusy(false);}\n }\n\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 8c15a66..2f9c3f7 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -2221,8 +2221,55 @@ body.mobile-mode .mobile-filter-bar { max-width: 520px; } +.tracker-table { + min-width: 960px; + table-layout: fixed; + width: 100%; +} + +.tracker-table th, +.tracker-table td { + overflow: hidden; + text-overflow: ellipsis; + vertical-align: middle; +} + +.tracker-table th:nth-child(1), +.tracker-table td:nth-child(1) { + width: 6%; +} + +.tracker-table th:nth-child(2), +.tracker-table td:nth-child(2) { + width: 38%; +} + +.tracker-table th:nth-child(3), +.tracker-table td:nth-child(3), +.tracker-table th:nth-child(4), +.tracker-table td:nth-child(4), +.tracker-table th:nth-child(5), +.tracker-table td:nth-child(5) { + width: 10%; +} + +.tracker-table th:nth-child(6), +.tracker-table td:nth-child(6) { + width: 16%; +} + +.tracker-table th:nth-child(7), +.tracker-table td:nth-child(7) { + width: 10%; +} + .tracker-url-text { - word-break: break-all; + display: block; + max-width: 100%; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .tool-note { @@ -5450,10 +5497,11 @@ body, .mobile-details-files-table { margin-bottom: 0; + min-width: 760px; } -.mobile-details-files-table { - min-width: 760px; +.mobile-details-trackers-table { + margin-bottom: 0; } .mobile-details-files-table .file-progress {