Db cleanup module #20
@@ -70,3 +70,9 @@ PYTORRENT_SESSION_COOKIE_SECURE=false
|
|||||||
# bypass auth on specific hosts (ex. local ip)
|
# bypass auth on specific hosts (ex. local ip)
|
||||||
PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11
|
PYTORRENT_AUTH_BYPASS_HOSTS=10.11.1.11:8090,10.11.1.11
|
||||||
PYTORRENT_AUTH_BYPASS_USER=admin
|
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
|
||||||
@@ -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.
|
# 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 ..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 ..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.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
|
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_cache import torrent_cache
|
||||||
from ..services.torrent_summary import cached_summary
|
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
|
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:
|
def _db_size() -> dict:
|
||||||
try:
|
try:
|
||||||
size = DB_PATH.stat().st_size if DB_PATH.exists() else 0
|
return database_maintenance.database_status()
|
||||||
return {"path": str(DB_PATH), "size": size, "size_h": rtorrent.human_size(size)}
|
|
||||||
except Exception as exc:
|
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:
|
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),
|
"operation_logs": operation_logs.retention_label(operation_log_retention),
|
||||||
},
|
},
|
||||||
"database": _db_size(),
|
"database": _db_size(),
|
||||||
|
"admin": is_admin(current_user()),
|
||||||
}
|
}
|
||||||
|
|
||||||
def active_default_download_path(profile: dict | None) -> str:
|
def active_default_download_path(profile: dict | None) -> str:
|
||||||
|
|||||||
@@ -203,6 +203,17 @@ def cleanup_jobs():
|
|||||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
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")
|
@bp.post("/cleanup/smart-queue")
|
||||||
def cleanup_smart_queue():
|
def cleanup_smart_queue():
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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); ";
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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 [`<span class=\"text-muted\">#${idx}</span>`, `<span class=\"tracker-url-text\">${url || '<span class=\"text-muted\">-</span>'}</span>`, t.enabled?'yes':'no', esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `<div class=\"tracker-actions\"><button class=\"btn btn-xs btn-outline-danger tracker-delete\" data-index=\"${idx}\"${deleteDisabled}><i class=\"fa-solid fa-trash\"></i> Delete</button></div>`];\n });\n // Note: Trackers share the responsive wrapper so long URLs do not break the details pane.\n pane.innerHTML=`<div class=\"tracker-toolbar\"><div class=\"input-group input-group-sm\"><input id=\"trackerAddUrl\" class=\"form-control tracker-add-input\" placeholder=\"https://tracker.example/announce\"><button id=\"trackerAddBtn\" class=\"btn btn-outline-primary\"><i class=\"fa-solid fa-plus\"></i> Add tracker</button></div><button id=\"trackerReannounceBtn\" class=\"btn btn-sm btn-outline-primary\"><i class=\"fa-solid fa-bullhorn\"></i> Reannounce</button></div>${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '<span class=\"text-muted\">-</span>','<span class=\"text-muted\">No trackers.</span>','','','','','' ]], '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 ? `<span class=\"tracker-url-text\" title=\"${esc(url)}\">${esc(url)}</span>` : '<span class=\"text-muted\">-</span>';\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 ? '<span class=\"badge text-bg-success\">yes</span>' : '<span class=\"badge text-bg-secondary\">no</span>';\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 [`<span class=\"text-muted\">#${idx}</span>`, trackerUrlCell(t), trackerEnabledCell(t.enabled), esc(trackerSeedsPeers(t)), esc(t.downloaded ?? '-'), fmtTs(t.last_announce), `<div class=\"tracker-actions\"><button class=\"btn btn-xs btn-outline-danger tracker-delete\" data-index=\"${idx}\"${deleteDisabled}><i class=\"fa-solid fa-trash\"></i> Delete</button></div>`];\n });\n // Note: Trackers now use the same fixed responsive table pattern as peers for consistent row text and spacing.\n pane.innerHTML=`<div class=\"tracker-toolbar\"><div class=\"input-group input-group-sm\"><input id=\"trackerAddUrl\" class=\"form-control tracker-add-input\" placeholder=\"https://tracker.example/announce\"><button id=\"trackerAddBtn\" class=\"btn btn-outline-primary\"><i class=\"fa-solid fa-plus\"></i> Add tracker</button></div><button id=\"trackerReannounceBtn\" class=\"btn btn-sm btn-outline-primary\"><i class=\"fa-solid fa-bullhorn\"></i> Reannounce</button></div>${responsiveTable(['#','URL','On','Seeds / Peers','Done','Last announce','Actions'], rows.length?rows:[[ '<span class=\"text-muted\">-</span>','<span class=\"text-muted\">No trackers.</span>','','','','','' ]], '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";
|
||||||
|
|||||||
@@ -2221,8 +2221,55 @@ body.mobile-mode .mobile-filter-bar {
|
|||||||
max-width: 520px;
|
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 {
|
.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 {
|
.tool-note {
|
||||||
@@ -5450,10 +5497,11 @@ body,
|
|||||||
|
|
||||||
.mobile-details-files-table {
|
.mobile-details-files-table {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
|
min-width: 760px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-details-files-table {
|
.mobile-details-trackers-table {
|
||||||
min-width: 760px;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-details-files-table .file-progress {
|
.mobile-details-files-table .file-progress {
|
||||||
|
|||||||
Reference in New Issue
Block a user