diff --git a/pytorrent/db.py b/pytorrent/db.py
index f27a89e..0565bee 100644
--- a/pytorrent/db.py
+++ b/pytorrent/db.py
@@ -512,6 +512,19 @@ CREATE TABLE IF NOT EXISTS operation_log_settings (
retention_mode TEXT DEFAULT 'days',
retention_days INTEGER DEFAULT 30,
retention_lines INTEGER DEFAULT 5000,
+ retention_interval_hours INTEGER DEFAULT 24,
+ job_retention_mode TEXT DEFAULT 'days',
+ job_retention_days INTEGER DEFAULT 7,
+ job_retention_lines INTEGER DEFAULT 2000,
+ job_retention_interval_hours INTEGER DEFAULT 24,
+ job_last_retention_run_at TEXT,
+ job_last_retention_deleted INTEGER DEFAULT 0,
+ operation_retention_mode TEXT DEFAULT 'days',
+ operation_retention_days INTEGER DEFAULT 30,
+ operation_retention_lines INTEGER DEFAULT 5000,
+ operation_retention_interval_hours INTEGER DEFAULT 24,
+ operation_last_retention_run_at TEXT,
+ operation_last_retention_deleted INTEGER DEFAULT 0,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
PRIMARY KEY(user_id, profile_id)
diff --git a/pytorrent/migrations.py b/pytorrent/migrations.py
index 0a0a57a..d9f0d23 100644
--- a/pytorrent/migrations.py
+++ b/pytorrent/migrations.py
@@ -95,9 +95,48 @@ def migrate_profile_preferences_sidebar_columns(conn: sqlite3.Connection) -> boo
return changed
+def migrate_operation_log_split_retention(conn: sqlite3.Connection) -> bool:
+ columns = _column_names(conn, "operation_log_settings")
+ changed = False
+ additions = {
+ "retention_interval_hours": "INTEGER DEFAULT 24",
+ "job_retention_mode": "TEXT DEFAULT 'days'",
+ "job_retention_days": "INTEGER DEFAULT 7",
+ "job_retention_lines": "INTEGER DEFAULT 2000",
+ "job_retention_interval_hours": "INTEGER DEFAULT 24",
+ "job_last_retention_run_at": "TEXT",
+ "job_last_retention_deleted": "INTEGER DEFAULT 0",
+ "operation_retention_mode": "TEXT DEFAULT 'days'",
+ "operation_retention_days": "INTEGER DEFAULT 30",
+ "operation_retention_lines": "INTEGER DEFAULT 5000",
+ "operation_retention_interval_hours": "INTEGER DEFAULT 24",
+ "operation_last_retention_run_at": "TEXT",
+ "operation_last_retention_deleted": "INTEGER DEFAULT 0",
+ }
+ for name, ddl in additions.items():
+ if name not in columns:
+ conn.execute(f"ALTER TABLE operation_log_settings ADD COLUMN {name} {ddl}")
+ changed = True
+ if changed:
+ conn.execute("""
+ UPDATE operation_log_settings
+ SET operation_retention_mode=COALESCE(operation_retention_mode, retention_mode, 'days'),
+ operation_retention_days=COALESCE(operation_retention_days, retention_days, 30),
+ operation_retention_lines=COALESCE(operation_retention_lines, retention_lines, 5000),
+ operation_retention_interval_hours=COALESCE(operation_retention_interval_hours, retention_interval_hours, 24),
+ job_retention_mode=COALESCE(job_retention_mode, 'days'),
+ job_retention_days=COALESCE(job_retention_days, 7),
+ job_retention_lines=COALESCE(job_retention_lines, 2000),
+ job_retention_interval_hours=COALESCE(job_retention_interval_hours, retention_interval_hours, 24),
+ updated_at=COALESCE(updated_at, ?)
+ """, (_utcnow(),))
+ return changed
+
+
MIGRATIONS: tuple[Migration, ...] = (
migrate_disk_monitor_preferences_to_profile_scope,
migrate_profile_preferences_sidebar_columns,
+ migrate_operation_log_split_retention,
)
diff --git a/pytorrent/routes/_shared.py b/pytorrent/routes/_shared.py
index 17ed555..bfbe712 100644
--- a/pytorrent/routes/_shared.py
+++ b/pytorrent/routes/_shared.py
@@ -249,13 +249,17 @@ def _safe_len(callable_obj) -> int | None:
except Exception:
return None
-def _table_count(table: str, where: str = "", params: tuple = ()) -> int:
- with connect() as conn:
- exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name=?", (table,)).fetchone()
- if not exists:
- return 0
- row = conn.execute(f"SELECT COUNT(*) AS n FROM {table} {where}", params).fetchone()
- return int((row or {}).get("n") or 0)
+def _table_count(table: str, where: str = "", params: tuple = (), conn=None) -> int:
+ """Count rows with one SQL statement; schema-created tables do not need a sqlite_master pre-check."""
+ try:
+ if conn is None:
+ with connect() as owned_conn:
+ row = owned_conn.execute(f"SELECT COUNT(*) AS n FROM {table} {where}", params).fetchone()
+ else:
+ row = conn.execute(f"SELECT COUNT(*) AS n FROM {table} {where}", params).fetchone()
+ return int((row or {}).get("n") or 0)
+ except Exception:
+ return 0
def _db_size() -> dict:
@@ -269,13 +273,13 @@ def _db_size() -> dict:
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, conn=None) -> dict:
profile = preferences.active_profile() if profile_id is None else {"id": profile_id}
profile_id = int((profile or {}).get("id") or 0)
if not profile_id:
return {"profile_id": 0, "profile_rows": 0, "runtime_items": 0}
- tracker_rows = _table_count("tracker_summary_cache", "WHERE profile_id=?", (profile_id,))
- stats_rows = _table_count("torrent_stats_cache", "WHERE profile_id=?", (profile_id,))
+ tracker_rows = _table_count("tracker_summary_cache", "WHERE profile_id=?", (profile_id,), conn=conn)
+ stats_rows = _table_count("torrent_stats_cache", "WHERE profile_id=?", (profile_id,), conn=conn)
runtime_items = 0
try:
runtime_items += len(torrent_cache.snapshot(profile_id))
@@ -287,21 +291,28 @@ def _active_profile_cache_summary(profile_id: int | None = None) -> dict:
def cleanup_summary() -> dict:
active_profile = preferences.active_profile()
profile_id = int((active_profile or {}).get("id") or 0)
- operation_logs_total = _table_count(
- "operation_logs",
- "WHERE profile_id=? OR profile_id IS NULL",
- (profile_id,),
- ) if profile_id else _table_count("operation_logs")
+ with connect() as conn:
+ operation_logs_total = _table_count(
+ "operation_logs",
+ "WHERE profile_id=? OR profile_id IS NULL",
+ (profile_id,),
+ conn=conn,
+ ) if profile_id else _table_count("operation_logs", conn=conn)
+ jobs_total = _table_count("jobs", conn=conn)
+ jobs_clearable = _table_count("jobs", "WHERE status NOT IN ('pending', 'running')", conn=conn)
+ smart_queue_history_total = _table_count("smart_queue_history", conn=conn)
+ automation_history_total = _table_count("automation_history", conn=conn)
+ cache_summary = _active_profile_cache_summary(profile_id if profile_id else None, conn=conn)
operation_log_retention = operation_logs.get_settings(profile_id) if profile_id else operation_logs.get_settings(0)
poller_runtime = poller_control.snapshot(profile_id) if profile_id else {}
return {
- "jobs_total": _table_count("jobs"),
- "jobs_clearable": _table_count("jobs", "WHERE status NOT IN ('pending', 'running')"),
- "smart_queue_history_total": _table_count("smart_queue_history"),
+ "jobs_total": jobs_total,
+ "jobs_clearable": jobs_clearable,
+ "smart_queue_history_total": smart_queue_history_total,
"operation_logs_total": operation_logs_total,
- "automation_history_total": _table_count("automation_history"),
+ "automation_history_total": automation_history_total,
"planner_history_total": download_planner.history_count(profile_id) if profile_id else 0,
- "cache": _active_profile_cache_summary(profile_id if profile_id else None),
+ "cache": cache_summary,
"poller_runtime": poller_runtime,
"retention_days": {
"jobs": JOBS_RETENTION_DAYS,
diff --git a/pytorrent/routes/operation_logs.py b/pytorrent/routes/operation_logs.py
index 6a6b620..d7a9695 100644
--- a/pytorrent/routes/operation_logs.py
+++ b/pytorrent/routes/operation_logs.py
@@ -16,7 +16,6 @@ def operation_logs_list():
profile = _active_profile_or_400()
if not profile:
return ok({"logs": [], "total": 0, "stats": {}, "settings": operation_logs.get_settings(0), "error": "No profile"})
- operation_logs.apply_retention(int(profile["id"]))
data = operation_logs.list_logs(
int(profile["id"]),
limit=int(request.args.get("limit") or 200),
@@ -25,11 +24,22 @@ def operation_logs_list():
q=str(request.args.get("q") or "").strip(),
hide_jobs=str(request.args.get("hide_jobs") or "").lower() in {"1", "true", "yes", "on"},
)
- data["stats"] = operation_logs.stats(int(profile["id"]))
- data["settings"] = data["stats"].get("settings")
+ data["settings"] = operation_logs.get_settings(int(profile["id"]))
+ if str(request.args.get("stats") or "").lower() in {"1", "true", "yes", "on"}:
+ data["stats"] = operation_logs.stats(int(profile["id"]))
+ data["settings"] = data["stats"].get("settings", data["settings"])
return ok(data)
+@bp.get("/operation-logs/stats")
+def operation_logs_stats():
+ profile = _active_profile_or_400()
+ if not profile:
+ return ok({"stats": {}, "settings": operation_logs.get_settings(0), "error": "No profile"})
+ stats = operation_logs.stats(int(profile["id"]))
+ return ok({"stats": stats, "settings": stats.get("settings")})
+
+
@bp.post("/operation-logs/settings")
def operation_logs_settings_save():
profile = _active_profile_or_400()
@@ -37,8 +47,7 @@ def operation_logs_settings_save():
return jsonify({"ok": False, "error": "No profile"}), 400
try:
settings = operation_logs.save_settings(int(profile["id"]), request.get_json(silent=True) or {})
- result = operation_logs.apply_retention(int(profile["id"]))
- return ok({"settings": settings, "retention": result})
+ return ok({"settings": settings})
except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}), 403 if isinstance(exc, PermissionError) else 400
@@ -57,4 +66,5 @@ def operation_logs_apply_retention():
profile = _active_profile_or_400()
if not profile:
return jsonify({"ok": False, "error": "No profile"}), 400
- return ok(operation_logs.apply_retention(int(profile["id"])))
+ category = str((request.get_json(silent=True) or {}).get("category") or "all").strip().lower()
+ return ok(operation_logs.apply_retention(int(profile["id"]), category=category))
diff --git a/pytorrent/routes/system.py b/pytorrent/routes/system.py
index 35ebd51..ffc99c8 100644
--- a/pytorrent/routes/system.py
+++ b/pytorrent/routes/system.py
@@ -86,6 +86,7 @@ def app_status():
jobs_total = jobs.get("total", 0)
except Exception:
jobs_total = 0
+ include_cleanup = str(request.args.get("cleanup") or "").lower() in {"1", "true", "yes", "on"}
status = {
"pytorrent": {
"ok": True,
@@ -103,10 +104,11 @@ def app_status():
"open_files": _safe_len(proc.open_files) if hasattr(proc, "open_files") else None,
"connections": _safe_len(lambda: proc.net_connections(kind="inet")) if hasattr(proc, "net_connections") else None,
},
- "cleanup": cleanup_summary(),
"profile": profile,
"scgi": None,
}
+ if include_cleanup:
+ status["cleanup"] = cleanup_summary()
if profile:
try:
status["scgi"] = rtorrent.scgi_diagnostics(profile)
diff --git a/pytorrent/services/operation_logs.py b/pytorrent/services/operation_logs.py
index d60eee3..a0d9058 100644
--- a/pytorrent/services/operation_logs.py
+++ b/pytorrent/services/operation_logs.py
@@ -6,8 +6,19 @@ from typing import Any
from ..db import connect, utcnow, default_user_id
from . import auth
-DEFAULT_SETTINGS = {"retention_mode": "days", "retention_days": 30, "retention_lines": 5000}
VALID_RETENTION_MODES = {"days", "lines", "both", "manual"}
+
+DEFAULT_SETTINGS = {
+ "retention_mode": "days",
+ "retention_days": 30,
+ "retention_lines": 5000,
+ "retention_interval_hours": 24,
+}
+DEFAULT_CATEGORY_SETTINGS = {
+ "job": {"retention_mode": "days", "retention_days": 7, "retention_lines": 2000, "retention_interval_hours": 24},
+ "operation": {"retention_mode": "days", "retention_days": 30, "retention_lines": 5000, "retention_interval_hours": 24},
+}
+VALID_LOG_CATEGORIES = {"job", "operation"}
MAX_DETAIL_TEXT = 4000
MAX_DETAIL_ITEMS = 200
@@ -99,6 +110,51 @@ def _row_to_public(row: dict) -> dict:
return item
+def _sanitize_mode(value: Any, default: str = "days") -> str:
+ mode = str(value or default).lower()
+ return mode if mode in VALID_RETENTION_MODES else default
+
+
+def _sanitize_days(value: Any, default: int) -> int:
+ return max(1, min(3650, int(value or default)))
+
+
+def _sanitize_lines(value: Any, default: int) -> int:
+ return max(100, min(1_000_000, int(value or default)))
+
+
+def _sanitize_interval(value: Any, default: int = 24) -> int:
+ return max(1, min(8760, int(value or default)))
+
+
+def _log_category(event_type: str = "", source: str = "") -> str:
+ return "job" if str(source or "") in {"job", "worker"} or str(event_type or "").startswith("job_") else "operation"
+
+
+def _category_where(category: str) -> str:
+ if category == "job":
+ return "(COALESCE(source, '') IN ('job', 'worker') OR event_type LIKE 'job_%')"
+ return "NOT (COALESCE(source, '') IN ('job', 'worker') OR event_type LIKE 'job_%')"
+
+
+def _parse_dt(value: Any) -> datetime | None:
+ if not value:
+ return None
+ try:
+ text = str(value).replace("Z", "+00:00")
+ dt = datetime.fromisoformat(text)
+ return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)
+ except Exception:
+ return None
+
+
+def _next_retention_run(settings: dict, category: str) -> str | None:
+ last = _parse_dt(settings.get(f"{category}_last_retention_run_at"))
+ if not last:
+ return None
+ return (last + timedelta(hours=int(settings.get(f"{category}_retention_interval_hours") or 24))).isoformat(timespec="seconds")
+
+
def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict:
user_id = _user_id(user_id)
profile_id = int(profile_id or 0)
@@ -108,51 +164,109 @@ def get_settings(profile_id: int = 0, user_id: int | None = None) -> dict:
(profile_id,),
).fetchone()
if not row:
- return {"owner_user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
- data = {**DEFAULT_SETTINGS, **dict(row)}
- data["owner_user_id"] = int(data.pop("user_id", user_id) or user_id)
- data["retention_mode"] = data.get("retention_mode") if data.get("retention_mode") in VALID_RETENTION_MODES else "days"
- data["retention_days"] = max(1, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"]))
- data["retention_lines"] = max(100, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"]))
+ data = {"owner_user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
+ else:
+ data = {**DEFAULT_SETTINGS, **dict(row)}
+ data["owner_user_id"] = int(data.pop("user_id", user_id) or user_id)
+ data["profile_id"] = profile_id
+ data["retention_mode"] = _sanitize_mode(data.get("retention_mode"), DEFAULT_SETTINGS["retention_mode"])
+ data["retention_days"] = _sanitize_days(data.get("retention_days"), DEFAULT_SETTINGS["retention_days"])
+ data["retention_lines"] = _sanitize_lines(data.get("retention_lines"), DEFAULT_SETTINGS["retention_lines"])
+ data["retention_interval_hours"] = _sanitize_interval(data.get("retention_interval_hours"), DEFAULT_SETTINGS["retention_interval_hours"])
+ for category, defaults in DEFAULT_CATEGORY_SETTINGS.items():
+ data[f"{category}_retention_mode"] = _sanitize_mode(data.get(f"{category}_retention_mode") or data.get("retention_mode"), defaults["retention_mode"])
+ data[f"{category}_retention_days"] = _sanitize_days(data.get(f"{category}_retention_days") or data.get("retention_days"), defaults["retention_days"])
+ data[f"{category}_retention_lines"] = _sanitize_lines(data.get(f"{category}_retention_lines") or data.get("retention_lines"), defaults["retention_lines"])
+ data[f"{category}_retention_interval_hours"] = _sanitize_interval(data.get(f"{category}_retention_interval_hours") or data.get("retention_interval_hours"), defaults["retention_interval_hours"])
+ data[f"{category}_last_retention_deleted"] = max(0, int(data.get(f"{category}_last_retention_deleted") or 0))
+ data[f"{category}_next_retention_run_at"] = _next_retention_run(data, category)
return data
def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> dict:
user_id = _user_id(user_id)
profile_id = int(profile_id or 0)
- mode = str(data.get("retention_mode") or "days").lower()
- if mode not in VALID_RETENTION_MODES:
- mode = "days"
- days = max(1, min(3650, int(data.get("retention_days") or DEFAULT_SETTINGS["retention_days"])))
- lines = max(100, min(1_000_000, int(data.get("retention_lines") or DEFAULT_SETTINGS["retention_lines"])))
now = utcnow()
if not auth.can_write_profile(profile_id, user_id):
raise PermissionError("No write access to profile")
+ current = get_settings(profile_id, user_id)
+ legacy_mode = _sanitize_mode(data.get("retention_mode") or current.get("retention_mode"), DEFAULT_SETTINGS["retention_mode"])
+ legacy_days = _sanitize_days(data.get("retention_days") or current.get("retention_days"), DEFAULT_SETTINGS["retention_days"])
+ legacy_lines = _sanitize_lines(data.get("retention_lines") or current.get("retention_lines"), DEFAULT_SETTINGS["retention_lines"])
+ legacy_interval = _sanitize_interval(data.get("retention_interval_hours") or current.get("retention_interval_hours"), DEFAULT_SETTINGS["retention_interval_hours"])
+ values: dict[str, Any] = {
+ "retention_mode": legacy_mode,
+ "retention_days": legacy_days,
+ "retention_lines": legacy_lines,
+ "retention_interval_hours": legacy_interval,
+ }
+ for category, defaults in DEFAULT_CATEGORY_SETTINGS.items():
+ values[f"{category}_retention_mode"] = _sanitize_mode(data.get(f"{category}_retention_mode") or current.get(f"{category}_retention_mode"), defaults["retention_mode"])
+ values[f"{category}_retention_days"] = _sanitize_days(data.get(f"{category}_retention_days") or current.get(f"{category}_retention_days"), defaults["retention_days"])
+ values[f"{category}_retention_lines"] = _sanitize_lines(data.get(f"{category}_retention_lines") or current.get(f"{category}_retention_lines"), defaults["retention_lines"])
+ values[f"{category}_retention_interval_hours"] = _sanitize_interval(data.get(f"{category}_retention_interval_hours") or current.get(f"{category}_retention_interval_hours"), defaults["retention_interval_hours"])
+ values[f"{category}_last_retention_run_at"] = current.get(f"{category}_last_retention_run_at")
+ values[f"{category}_last_retention_deleted"] = int(current.get(f"{category}_last_retention_deleted") or 0)
with connect() as conn:
- conn.execute("DELETE FROM operation_log_settings WHERE profile_id=?", (profile_id,))
conn.execute(
"""
- INSERT INTO operation_log_settings(user_id, profile_id, retention_mode, retention_days, retention_lines, created_at, updated_at)
- VALUES(?,?,?,?,?,?,?)
+ INSERT INTO operation_log_settings(
+ user_id, profile_id, retention_mode, retention_days, retention_lines,
+ retention_interval_hours,
+ job_retention_mode, job_retention_days, job_retention_lines, job_retention_interval_hours, job_last_retention_run_at, job_last_retention_deleted,
+ operation_retention_mode, operation_retention_days, operation_retention_lines, operation_retention_interval_hours, operation_last_retention_run_at, operation_last_retention_deleted,
+ created_at, updated_at
+ ) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
+ ON CONFLICT(user_id, profile_id) DO UPDATE SET
+ retention_mode=excluded.retention_mode,
+ retention_days=excluded.retention_days,
+ retention_lines=excluded.retention_lines,
+ retention_interval_hours=excluded.retention_interval_hours,
+ job_retention_mode=excluded.job_retention_mode,
+ job_retention_days=excluded.job_retention_days,
+ job_retention_lines=excluded.job_retention_lines,
+ job_retention_interval_hours=excluded.job_retention_interval_hours,
+ job_last_retention_run_at=excluded.job_last_retention_run_at,
+ job_last_retention_deleted=excluded.job_last_retention_deleted,
+ operation_retention_mode=excluded.operation_retention_mode,
+ operation_retention_days=excluded.operation_retention_days,
+ operation_retention_lines=excluded.operation_retention_lines,
+ operation_retention_interval_hours=excluded.operation_retention_interval_hours,
+ operation_last_retention_run_at=excluded.operation_last_retention_run_at,
+ operation_last_retention_deleted=excluded.operation_last_retention_deleted,
+ updated_at=excluded.updated_at
""",
- (user_id, profile_id, mode, days, lines, now, now),
+ (
+ user_id, profile_id, values["retention_mode"], values["retention_days"], values["retention_lines"], values["retention_interval_hours"],
+ values["job_retention_mode"], values["job_retention_days"], values["job_retention_lines"], values["job_retention_interval_hours"], values["job_last_retention_run_at"], values["job_last_retention_deleted"],
+ values["operation_retention_mode"], values["operation_retention_days"], values["operation_retention_lines"], values["operation_retention_interval_hours"], values["operation_last_retention_run_at"], values["operation_last_retention_deleted"],
+ now, now,
+ ),
)
return get_settings(profile_id, user_id)
def record(profile_id: int | None, event_type: str, message: str, *, severity: str = "info", source: str = "system", torrent_hash: str | None = None, torrent_name: str | None = None, action: str | None = None, details: dict | None = None, user_id: int | None = None) -> int:
- """Insert one operation log row and keep all details in JSON-safe form."""
+ """Insert one operation log row and lazily run retention for its category when due."""
now = utcnow()
user_id = _user_id(user_id)
+ event_type_s = str(event_type)
+ source_s = str(source or "system")
with connect() as conn:
cur = conn.execute(
"""
INSERT INTO operation_logs(user_id, profile_id, event_type, severity, source, torrent_hash, torrent_name, action, message, details_json, created_at)
VALUES(?,?,?,?,?,?,?,?,?,?,?)
""",
- (user_id, int(profile_id or 0) or None, str(event_type), str(severity or "info"), str(source or "system"), torrent_hash, torrent_name, action, str(message), _details(details), now),
+ (user_id, int(profile_id or 0) or None, event_type_s, str(severity or "info"), source_s, torrent_hash, torrent_name, action, str(message), _details(details), now),
)
- return int(cur.lastrowid)
+ row_id = int(cur.lastrowid)
+ try:
+ maybe_apply_retention(int(profile_id or 0), _log_category(event_type_s, source_s), user_id=user_id)
+ except Exception:
+ # Logging must never fail because cleanup metadata could not be updated.
+ pass
+ return row_id
def _job_event_type(status: str) -> str:
@@ -282,7 +396,7 @@ def list_logs(profile_id: int, *, limit: int = 200, offset: int = 0, event_type:
where.append("event_type=?")
params.append(event_type)
if hide_jobs:
- where.append("COALESCE(source, '') <> 'job' AND event_type NOT LIKE 'job_%'")
+ where.append("COALESCE(source, '') NOT IN ('job', 'worker') AND event_type NOT LIKE 'job_%'")
if q:
where.append("(message LIKE ? OR torrent_name LIKE ? OR torrent_hash LIKE ? OR action LIKE ? OR details_json LIKE ?)")
like = f"%{q}%"
@@ -305,20 +419,29 @@ def stats(profile_id: int) -> dict:
return {"total": int(total or 0), "by_type": by_type, "by_day": by_day, "by_month": by_month, "top_actions": top_actions, "settings": get_settings(profile_id)}
-def retention_label(settings: dict) -> str:
- mode = settings.get("retention_mode") or "days"
+def _retention_label_for(settings: dict, category: str) -> str:
+ mode = settings.get(f"{category}_retention_mode") or "days"
+ days = settings.get(f"{category}_retention_days") or DEFAULT_CATEGORY_SETTINGS[category]["retention_days"]
+ lines = settings.get(f"{category}_retention_lines") or DEFAULT_CATEGORY_SETTINGS[category]["retention_lines"]
+ interval = settings.get(f"{category}_retention_interval_hours") or DEFAULT_CATEGORY_SETTINGS[category]["retention_interval_hours"]
if mode == "manual":
- return "manual cleanup only"
+ return f"manual cleanup only, checked every {interval}h"
if mode == "lines":
- return f"retention {settings.get('retention_lines') or DEFAULT_SETTINGS['retention_lines']} lines"
+ return f"retention {lines} lines, checked every {interval}h"
if mode == "both":
- return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days and {settings.get('retention_lines') or DEFAULT_SETTINGS['retention_lines']} lines"
- return f"retention {settings.get('retention_days') or DEFAULT_SETTINGS['retention_days']} days"
+ return f"retention {days} days and {lines} lines, checked every {interval}h"
+ return f"retention {days} days, checked every {interval}h"
-def clear(profile_id: int, *, event_type: str = "") -> int:
+def retention_label(settings: dict) -> str:
+ return f"Jobs: {_retention_label_for(settings, 'job')} / Operations: {_retention_label_for(settings, 'operation')}"
+
+
+def clear(profile_id: int, *, event_type: str = "", category: str = "") -> int:
where = ["(profile_id=? OR profile_id IS NULL)"]
params: list[Any] = [int(profile_id or 0)]
+ if category in VALID_LOG_CATEGORIES:
+ where.append(_category_where(category))
if event_type:
where.append("event_type=?")
params.append(event_type)
@@ -327,22 +450,88 @@ def clear(profile_id: int, *, event_type: str = "") -> int:
return int(cur.rowcount or 0)
-def apply_retention(profile_id: int, user_id: int | None = None) -> dict:
- """Apply operation-log retention without touching torrent data or other history tables."""
- settings = get_settings(profile_id, user_id)
- mode = settings.get("retention_mode") or "manual"
+def _apply_retention_category(conn, profile_id: int, settings: dict, category: str) -> dict:
+ mode = settings.get(f"{category}_retention_mode") or "manual"
deleted_days = 0
deleted_lines = 0
+ base_where = f"(profile_id=? OR profile_id IS NULL) AND {_category_where(category)}"
+ if mode in {"days", "both"}:
+ cutoff = (datetime.now(timezone.utc) - timedelta(days=int(settings[f"{category}_retention_days"]))).isoformat(timespec="seconds")
+ cur = conn.execute(f"DELETE FROM operation_logs WHERE {base_where} AND created_at", (int(profile_id or 0), cutoff))
+ deleted_days = int(cur.rowcount or 0)
+ if mode in {"lines", "both"}:
+ keep = int(settings[f"{category}_retention_lines"])
+ cur = conn.execute(
+ f"""
+ DELETE FROM operation_logs
+ WHERE id IN (
+ SELECT id FROM operation_logs
+ WHERE {base_where}
+ ORDER BY id DESC
+ LIMIT -1 OFFSET ?
+ )
+ """,
+ (int(profile_id or 0), keep),
+ )
+ deleted_lines = int(cur.rowcount or 0)
+ return {"deleted_days": deleted_days, "deleted_lines": deleted_lines, "deleted": deleted_days + deleted_lines}
+
+
+def _update_retention_metadata(conn, profile_id: int, category: str, deleted: int, settings: dict, user_id: int | None = None) -> None:
+ now = utcnow()
+ owner_id = int(settings.get("owner_user_id") or _user_id(user_id))
+ profile_id = int(profile_id or 0)
+ cur = conn.execute(
+ f"""
+ UPDATE operation_log_settings
+ SET {category}_last_retention_run_at=?, {category}_last_retention_deleted=?, updated_at=?
+ WHERE profile_id=?
+ """,
+ (now, int(deleted or 0), now, profile_id),
+ )
+ if int(cur.rowcount or 0) == 0:
+ conn.execute(
+ """
+ INSERT INTO operation_log_settings(user_id, profile_id, created_at, updated_at)
+ VALUES(?,?,?,?)
+ ON CONFLICT(user_id, profile_id) DO UPDATE SET updated_at=excluded.updated_at
+ """,
+ (owner_id, profile_id, now, now),
+ )
+ conn.execute(
+ f"UPDATE operation_log_settings SET {category}_last_retention_run_at=?, {category}_last_retention_deleted=?, updated_at=? WHERE profile_id=?",
+ (now, int(deleted or 0), now, profile_id),
+ )
+
+
+def apply_retention(profile_id: int, user_id: int | None = None, category: str = "all") -> dict:
+ """Apply due operation-log retention without touching torrent data or other history tables."""
+ profile_id = int(profile_id or 0)
+ settings = get_settings(profile_id, user_id)
+ categories = [category] if category in VALID_LOG_CATEGORIES else ["job", "operation"]
+ results: dict[str, Any] = {}
+ total = 0
with connect() as conn:
- if mode in {"days", "both"}:
- cutoff = (datetime.now(timezone.utc) - timedelta(days=int(settings["retention_days"]))).isoformat(timespec="seconds")
- cur = conn.execute("DELETE FROM operation_logs WHERE (profile_id=? OR profile_id IS NULL) AND created_at", (int(profile_id or 0), cutoff))
- deleted_days = int(cur.rowcount or 0)
- if mode in {"lines", "both"}:
- keep = int(settings["retention_lines"])
- ids = conn.execute("SELECT id FROM operation_logs WHERE profile_id=? OR profile_id IS NULL ORDER BY id DESC LIMIT -1 OFFSET ?", (int(profile_id or 0), keep)).fetchall()
- if ids:
- placeholders = ",".join("?" for _ in ids)
- cur = conn.execute(f"DELETE FROM operation_logs WHERE id IN ({placeholders})", tuple(r["id"] for r in ids))
- deleted_lines = int(cur.rowcount or 0)
- return {"deleted_days": deleted_days, "deleted_lines": deleted_lines, "deleted": deleted_days + deleted_lines, "settings": settings}
+ for cat in categories:
+ item = _apply_retention_category(conn, profile_id, settings, cat)
+ _update_retention_metadata(conn, profile_id, cat, int(item["deleted"]), settings, user_id=user_id)
+ results[cat] = item
+ total += int(item["deleted"])
+ fresh = get_settings(profile_id, user_id)
+ return {"deleted": total, "categories": results, "settings": fresh}
+
+
+def maybe_apply_retention(profile_id: int, category: str, user_id: int | None = None) -> dict:
+ """Run retention for a category only when interval since last cleanup elapsed."""
+ if category not in VALID_LOG_CATEGORIES:
+ category = "operation"
+ settings = get_settings(profile_id, user_id)
+ interval = int(settings.get(f"{category}_retention_interval_hours") or 24)
+ last = _parse_dt(settings.get(f"{category}_last_retention_run_at"))
+ now = datetime.now(timezone.utc)
+ if last and now < last + timedelta(hours=interval):
+ return {"skipped": True, "category": category, "next_run_at": (last + timedelta(hours=interval)).isoformat(timespec="seconds"), "settings": settings}
+ result = apply_retention(profile_id, user_id=user_id, category=category)
+ result["skipped"] = False
+ result["category"] = category
+ return result
diff --git a/pytorrent/static/js/diagnosticsDashboard.js b/pytorrent/static/js/diagnosticsDashboard.js
index 4cd41c2..2cca75e 100644
--- a/pytorrent/static/js/diagnosticsDashboard.js
+++ b/pytorrent/static/js/diagnosticsDashboard.js
@@ -1 +1 @@
-export const diagnosticsDashboardSource = "function diagnosticsSection(title, cards){\n return ` ${esc(title)}
${cards.join('')}
`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=` Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)} `;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`
${esc(scgi.error)}
`:''].join('');\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n}\n";
+export const diagnosticsDashboardSource = "function diagnosticsSection(title, cards){\n return ` ${esc(title)}
${cards.join('')}
`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status?cleanup=1').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=` Smart Queue decisions
${renderSmartQueueNerdStats(smartStats)} `;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`${esc(scgi.error)}
`:''].join('');\n }catch(e){ box.innerHTML=`${esc(e.message)}
`; }\n}\n";
diff --git a/pytorrent/static/js/operationLogs.js b/pytorrent/static/js/operationLogs.js
index ff4d675..f27dbe1 100644
--- a/pytorrent/static/js/operationLogs.js
+++ b/pytorrent/static/js/operationLogs.js
@@ -1 +1 @@
-export const operationLogsSource = " let operationLogsPage = 0;\n let operationLogsLastData = null;\n const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = 'pytorrent.operationLogView.v4';\n const OPERATION_LOG_TYPES = ['', 'torrent_added', 'torrent_removed', 'torrent_completed', 'job_queued', 'job_started', 'job_done', 'job_failed', 'job_retry', 'job_cancelled', 'job_timeout', 'job_resubmitted', 'job_forced'];\n\n function readOperationLogViewPrefs(){\n try{\n const current = JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || '{}') || {};\n const previousV3 = JSON.parse(localStorage.getItem('pytorrent.operationLogView.v3') || '{}') || {};\n const previousV2 = JSON.parse(localStorage.getItem('pytorrent.operationLogView.v2') || '{}') || {};\n const previousV1 = JSON.parse(localStorage.getItem('pytorrent.operationLogView.v1') || '{}') || {};\n // Note: Older detail visibility is intentionally not migrated, so Show details starts disabled by default.\n const {showDetails: _oldShowDetailsV3, ...safePreviousV3} = previousV3;\n const {showDetails: _oldShowDetailsV2, ...safePreviousV2} = previousV2;\n const {showDetails: _oldShowDetailsV1, ...safePreviousV1} = previousV1;\n return {...safePreviousV1, ...safePreviousV2, ...safePreviousV3, ...current};\n }catch(e){ return {}; }\n }\n\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {\n defaultType: String(prefs.defaultType ?? ''),\n hideJobs: prefs.hideJobs !== false,\n showDetails: prefs.showDetails === true,\n };\n }\n\n function operationLogBadge(type, severity){\n const cls = severity === 'danger' ? 'danger' : severity === 'warning' ? 'warning' : type === 'torrent_completed' ? 'success' : type === 'torrent_removed' ? 'secondary' : 'info';\n return `${esc(type || 'log')} `;\n }\n\n function operationLogTypeLabel(type){\n const labels = {\n '': 'All types',\n torrent_added: 'Torrent added',\n torrent_removed: 'Torrent removed',\n torrent_completed: 'Torrent completed',\n job_queued: 'Job queued',\n job_started: 'Job started',\n job_done: 'Job done',\n job_failed: 'Job failed',\n job_retry: 'Job retry',\n job_cancelled: 'Job cancelled',\n job_timeout: 'Job timeout',\n job_resubmitted: 'Job resubmitted',\n job_forced: 'Job forced'\n };\n return labels[type] || type || 'All types';\n }\n\n function renderOperationLogStats(stats={}){\n const card = (label, value) => `${esc(label)} ${esc(value ?? 0)}
`;\n const types = (stats.by_type || []).map(x => card(x.event_type || 'unknown', x.n)).join('');\n const daily = (stats.by_day || []).map(x => `${esc(x.bucket)} ${esc(x.n)}
`).join('');\n const monthly = (stats.by_month || []).map(x => `${esc(x.bucket)} ${esc(x.n)}
`).join('');\n const actions = (stats.top_actions || []).map(x => `${esc(x.action)} ${esc(x.n)}
`).join('');\n return `${card('Total logs', stats.total || 0)}${types}
Daily count ${daily || 'No data. '}Monthly count ${monthly || 'No data. '}Top actions ${actions || 'No data. '}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n if($('operationLogRetentionMode')) $('operationLogRetentionMode').value = settings.retention_mode || 'days';\n if($('operationLogRetentionDays')) $('operationLogRetentionDays').value = settings.retention_days || 30;\n if($('operationLogRetentionLines')) $('operationLogRetentionLines').value = settings.retention_lines || 5000;\n }\n\n function operationLogQuery(){\n const params = new URLSearchParams();\n params.set('limit', String(operationLogsLimit));\n params.set('offset', String(operationLogsPage * operationLogsLimit));\n const prefs = operationLogViewPrefs();\n const type = $('operationLogTypeFilter')?.value ?? prefs.defaultType;\n const q = $('operationLogSearch')?.value || '';\n const hideJobs = $('operationLogHideJobs') ? $('operationLogHideJobs').checked : prefs.hideJobs;\n if(type) params.set('type', type);\n if(q) params.set('q', q);\n if(hideJobs) params.set('hide_jobs', '1');\n return params.toString();\n }\n\n function operationLogTorrentCell(row){\n const value = row.torrent_name || row.torrent_hash || '-';\n // Note: A fixed character cap prevents long torrent names from stretching the Logs modal; the full value stays in the tooltip.\n return compactCell(value, 42);\n }\n\n function operationLogDetailLabel(key){\n return String(key || '')\n .replace(/_/g, ' ')\n .replace(/\\b\\w/g, x => x.toUpperCase());\n }\n\n function operationLogFormatBytes(value){\n const bytes = Number(value);\n if(!Number.isFinite(bytes) || bytes < 0) return String(value ?? '');\n const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];\n let size = bytes;\n let unit = 0;\n while(size >= 1024 && unit < units.length - 1){ size /= 1024; unit += 1; }\n const digits = unit === 0 ? 0 : size >= 100 ? 0 : size >= 10 ? 1 : 2;\n return `${size.toFixed(digits)} ${units[unit]}`;\n }\n\n function operationLogFormatDetailValue(key, value){\n if(value === null || value === undefined || value === '') return '';\n const name = String(key || '').toLowerCase();\n if((name === 'size' || name.endsWith('_size') || name.endsWith('_bytes') || name === 'bytes') && Number.isFinite(Number(value))){\n return operationLogFormatBytes(value);\n }\n if(typeof value === 'boolean') return value ? 'yes' : 'no';\n if(Array.isArray(value)){\n if(!value.length) return '';\n if(value.length <= 3 && value.every(item => item === null || ['string', 'number', 'boolean'].includes(typeof item))){\n return value.map(item => String(item)).join(', ');\n }\n return `${value.length} item(s)`;\n }\n if(typeof value === 'object'){\n const entries = Object.entries(value).filter(([, item]) => item !== null && item !== undefined && item !== '');\n if(!entries.length) return '';\n if(entries.length <= 3 && entries.every(([, item]) => ['string', 'number', 'boolean'].includes(typeof item))){\n return entries.map(([childKey, item]) => `${operationLogDetailLabel(childKey)}: ${operationLogFormatDetailValue(childKey, item)}`).join(', ');\n }\n return `${entries.length} field(s)`;\n }\n return String(value);\n }\n\n function operationLogDetailEntries(details){\n const data = details && typeof details === 'object' && !Array.isArray(details) ? details : {};\n return Object.entries(data)\n .map(([key, value]) => [key, operationLogFormatDetailValue(key, value)])\n .filter(([, value]) => value);\n }\n\n function operationLogDetailsCell(row){\n const entries = operationLogDetailEntries(row.details);\n if(!entries.length) return 'No details. ';\n // Note: Details use a compact key-value table to keep the Logs modal narrow and easy to scan.\n return `Details ${entries.map(([key, value]) => `${esc(operationLogDetailLabel(key))} ${compactCell(value, 160)} `).join('')}
`;\n }\n\n function operationLogColumns(){\n return [\n ['Time', 'operation-log-col-time'],\n ['Type', 'operation-log-col-type'],\n ['Source', 'operation-log-col-source'],\n ['Action', 'operation-log-col-action'],\n ['Torrent', 'operation-log-col-torrent'],\n ['Message', 'operation-log-col-message'],\n ];\n }\n\n function operationLogRowCells(row){\n return [\n humanDateCell(row.created_at),\n operationLogBadge(row.event_type, row.severity),\n esc(row.source || '-'),\n esc(row.action || '-'),\n operationLogTorrentCell(row),\n compactCell(row.message || '', 260),\n ];\n }\n\n function operationLogTable(rows){\n const columns = operationLogColumns();\n const showDetails = operationLogViewPrefs().showDetails;\n const head = `${columns.map(([label]) => `${esc(label)} `).join('')} `;\n const colgroup = `${columns.map(([, cls]) => ` `).join('')} `;\n const body = rows.map(row => {\n const main = `${operationLogRowCells(row).map(cell => `${cell} `).join('')} `;\n const details = showDetails ? `${operationLogDetailsCell(row)} ` : '';\n return main + details;\n }).join('');\n return `${colgroup}${head}${body}
`;\n }\n\n function renderOperationLogs(data={}){\n const box = $('operationLogsTable');\n if(!box) return;\n const rows = data.logs || [];\n const total = Number(data.total || 0);\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').dataset.ready){\n const prefs = operationLogViewPrefs();\n $('operationLogTypeFilter').innerHTML = OPERATION_LOG_TYPES.map(t => `${esc(operationLogTypeLabel(t))} `).join('');\n $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n $('operationLogTypeFilter').dataset.ready = '1';\n }\n box.innerHTML = operationLogTable(rows);\n if(!rows.length) box.innerHTML = 'No logs. No entries match current filters.
';\n const pages = Math.max(1, Math.ceil(total / operationLogsLimit));\n if($('operationLogsPager')) $('operationLogsPager').innerHTML = `Prev Page ${operationLogsPage + 1} / ${pages} ${total} logs = pages - 1 ? 'disabled' : ''}>Next `;\n $('operationLogsPrev')?.addEventListener('click', () => { operationLogsPage = Math.max(0, operationLogsPage - 1); loadOperationLogs(); });\n $('operationLogsNext')?.addEventListener('click', () => { operationLogsPage += 1; loadOperationLogs(); });\n if($('operationLogStats')) $('operationLogStats').innerHTML = renderOperationLogStats(data.stats || {});\n fillOperationLogSettings(data.settings || data.stats?.settings || {});\n }\n\n function syncOperationLogViewControls(){\n const prefs = operationLogViewPrefs();\n if($('operationLogDefaultType')) $('operationLogDefaultType').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n if($('operationLogHideJobsDefault')) $('operationLogHideJobsDefault').checked = prefs.hideJobs;\n if($('operationLogHideJobs')) $('operationLogHideJobs').checked = prefs.hideJobs;\n if($('operationLogShowDetails')) $('operationLogShowDetails').checked = prefs.showDetails;\n if($('operationLogShowDetailsDefault')) $('operationLogShowDetailsDefault').checked = prefs.showDetails;\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').value) $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n }\n\n function saveOperationLogViewSettings(){\n const current = operationLogViewPrefs();\n const prefs = {\n defaultType: $('operationLogDefaultType')?.value || current.defaultType || '',\n hideJobs: $('operationLogHideJobsDefault') ? $('operationLogHideJobsDefault').checked : current.hideJobs,\n showDetails: $('operationLogShowDetailsDefault') ? $('operationLogShowDetailsDefault').checked : current.showDetails,\n };\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast('Log view settings saved.', 'success');\n loadOperationLogs(true);\n }\n\n function saveOperationLogDetailsPreference(){\n const prefs = {...operationLogViewPrefs(), showDetails: $('operationLogShowDetails')?.checked === true};\n saveOperationLogViewPrefs(prefs);\n if($('operationLogShowDetailsDefault')) $('operationLogShowDetailsDefault').checked = prefs.showDetails;\n renderOperationLogs(operationLogsLastData || {});\n }\n\n async function loadOperationLogs(reset=false){\n const box = $('operationLogsTable');\n if(!box) return;\n if(reset) operationLogsPage = 0;\n box.innerHTML = ' Loading logs...';\n try{\n const data = await fetch(`/api/operation-logs?${operationLogQuery()}`, {cache: 'no-store'}).then(r => r.json());\n if(!data.ok) throw new Error(data.error || 'Cannot load logs');\n operationLogsLastData = data;\n renderOperationLogs(data);\n }catch(e){\n box.innerHTML = `${esc(e.message)}
`;\n }\n }\n\n async function saveOperationLogSettings(){\n try{\n const data = await post('/api/operation-logs/settings', {\n retention_mode: $('operationLogRetentionMode')?.value || 'days',\n retention_days: Number($('operationLogRetentionDays')?.value || 30),\n retention_lines: Number($('operationLogRetentionLines')?.value || 5000),\n });\n fillOperationLogSettings(data.settings || {});\n toast(`Log retention saved. Deleted ${data.retention?.deleted || 0} old entries.`, 'success');\n loadOperationLogs(true);\n }catch(e){ toast(e.message, 'danger'); }\n }\n\n function bindOperationLogEvents(){\n syncOperationLogViewControls();\n $('logsModal')?.addEventListener('show.bs.modal', () => { syncOperationLogViewControls(); loadOperationLogs(true); });\n $('refreshOperationLogsBtn')?.addEventListener('click', () => loadOperationLogs(true));\n $('operationLogTypeFilter')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogHideJobs')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogShowDetails')?.addEventListener('change', saveOperationLogDetailsPreference);\n $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogViewBtn')?.addEventListener('click', saveOperationLogViewSettings);\n $('operationLogDefaultType')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogHideJobsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogShowDetailsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('saveOperationLogRetentionBtn')?.addEventListener('click', saveOperationLogSettings);\n $('applyOperationLogRetentionBtn')?.addEventListener('click', async () => { try{ const j = await post('/api/operation-logs/apply-retention', {}); toast(`Deleted ${j.deleted || 0} old log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n $('clearOperationLogsBtn')?.addEventListener('click', async () => { if(!confirm('Clear operation logs for this profile?')) return; try{ const j = await post('/api/operation-logs/clear', {event_type: $('operationLogTypeFilter')?.value || ''}); toast(`Deleted ${j.deleted || 0} log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n }\n";
+export const operationLogsSource = " let operationLogsPage = 0;\n let operationLogsLastData = null;\n const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = 'pytorrent.operationLogView.v4';\n const OPERATION_LOG_TYPES = ['', 'torrent_added', 'torrent_removed', 'torrent_completed', 'job_queued', 'job_started', 'job_done', 'job_failed', 'job_retry', 'job_cancelled', 'job_timeout', 'job_resubmitted', 'job_forced'];\n\n function readOperationLogViewPrefs(){\n try{\n const current = JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || '{}') || {};\n const previousV3 = JSON.parse(localStorage.getItem('pytorrent.operationLogView.v3') || '{}') || {};\n const previousV2 = JSON.parse(localStorage.getItem('pytorrent.operationLogView.v2') || '{}') || {};\n const previousV1 = JSON.parse(localStorage.getItem('pytorrent.operationLogView.v1') || '{}') || {};\n // Note: Older detail visibility is intentionally not migrated, so Show details starts disabled by default.\n const {showDetails: _oldShowDetailsV3, ...safePreviousV3} = previousV3;\n const {showDetails: _oldShowDetailsV2, ...safePreviousV2} = previousV2;\n const {showDetails: _oldShowDetailsV1, ...safePreviousV1} = previousV1;\n return {...safePreviousV1, ...safePreviousV2, ...safePreviousV3, ...current};\n }catch(e){ return {}; }\n }\n\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {\n defaultType: String(prefs.defaultType ?? ''),\n hideJobs: prefs.hideJobs !== false,\n showDetails: prefs.showDetails === true,\n };\n }\n\n function operationLogBadge(type, severity){\n const cls = severity === 'danger' ? 'danger' : severity === 'warning' ? 'warning' : type === 'torrent_completed' ? 'success' : type === 'torrent_removed' ? 'secondary' : 'info';\n return `${esc(type || 'log')} `;\n }\n\n function operationLogTypeLabel(type){\n const labels = {\n '': 'All types',\n torrent_added: 'Torrent added',\n torrent_removed: 'Torrent removed',\n torrent_completed: 'Torrent completed',\n job_queued: 'Job queued',\n job_started: 'Job started',\n job_done: 'Job done',\n job_failed: 'Job failed',\n job_retry: 'Job retry',\n job_cancelled: 'Job cancelled',\n job_timeout: 'Job timeout',\n job_resubmitted: 'Job resubmitted',\n job_forced: 'Job forced'\n };\n return labels[type] || type || 'All types';\n }\n\n function operationLogRetentionMeta(settings={}, category){\n const last = settings[`${category}_last_retention_run_at`];\n const next = settings[`${category}_next_retention_run_at`];\n const deleted = settings[`${category}_last_retention_deleted`] ?? 0;\n return `Last run: ${last ? humanDateCell(last) : '-'} Next run: ${next ? humanDateCell(next) : 'after next due log'} Last deleted: ${esc(deleted)}
`;\n }\n\n function renderOperationLogStats(stats={}){\n const card = (label, value) => `${esc(label)} ${esc(value ?? 0)}
`;\n const types = (stats.by_type || []).map(x => card(x.event_type || 'unknown', x.n)).join('');\n const daily = (stats.by_day || []).map(x => `${esc(x.bucket)} ${esc(x.n)}
`).join('');\n const monthly = (stats.by_month || []).map(x => `${esc(x.bucket)} ${esc(x.n)}
`).join('');\n const actions = (stats.top_actions || []).map(x => `${esc(x.action)} ${esc(x.n)}
`).join('');\n const settings = stats.settings || {};\n const retention = `Job retention ${operationLogRetentionMeta(settings, 'job')}Operation retention ${operationLogRetentionMeta(settings, 'operation')}
`;\n return `${card('Total logs', stats.total || 0)}${types}
${retention}Daily count ${daily || 'No data. '}Monthly count ${monthly || 'No data. '}Top actions ${actions || 'No data. '}
`;\n }\n\n function fillOperationLogSettings(settings={}){\n [['job',7,2000], ['operation',30,5000]].forEach(([category, daysDefault, linesDefault]) => {\n const cap = category === 'job' ? 'Job' : 'Operation';\n const mode = $(`operationLog${cap}RetentionMode`);\n const days = $(`operationLog${cap}RetentionDays`);\n const lines = $(`operationLog${cap}RetentionLines`);\n const interval = $(`operationLog${cap}RetentionInterval`);\n const meta = $(`operationLog${cap}RetentionMeta`);\n if(mode) mode.value = settings[`${category}_retention_mode`] || 'days';\n if(days) days.value = settings[`${category}_retention_days`] || daysDefault;\n if(lines) lines.value = settings[`${category}_retention_lines`] || linesDefault;\n if(interval) interval.value = settings[`${category}_retention_interval_hours`] || 24;\n if(meta) meta.innerHTML = operationLogRetentionMeta(settings, category);\n });\n }\n\n function operationLogQuery(){\n const params = new URLSearchParams();\n params.set('limit', String(operationLogsLimit));\n params.set('offset', String(operationLogsPage * operationLogsLimit));\n const prefs = operationLogViewPrefs();\n const type = $('operationLogTypeFilter')?.value ?? prefs.defaultType;\n const q = $('operationLogSearch')?.value || '';\n const hideJobs = $('operationLogHideJobs') ? $('operationLogHideJobs').checked : prefs.hideJobs;\n if(type) params.set('type', type);\n if(q) params.set('q', q);\n if(hideJobs) params.set('hide_jobs', '1');\n return params.toString();\n }\n\n function operationLogTorrentCell(row){\n const value = row.torrent_name || row.torrent_hash || '-';\n // Note: A fixed character cap prevents long torrent names from stretching the Logs modal; the full value stays in the tooltip.\n return compactCell(value, 42);\n }\n\n function operationLogDetailLabel(key){\n return String(key || '')\n .replace(/_/g, ' ')\n .replace(/\\b\\w/g, x => x.toUpperCase());\n }\n\n function operationLogFormatBytes(value){\n const bytes = Number(value);\n if(!Number.isFinite(bytes) || bytes < 0) return String(value ?? '');\n const units = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];\n let size = bytes;\n let unit = 0;\n while(size >= 1024 && unit < units.length - 1){ size /= 1024; unit += 1; }\n const digits = unit === 0 ? 0 : size >= 100 ? 0 : size >= 10 ? 1 : 2;\n return `${size.toFixed(digits)} ${units[unit]}`;\n }\n\n function operationLogFormatDetailValue(key, value){\n if(value === null || value === undefined || value === '') return '';\n const name = String(key || '').toLowerCase();\n if((name === 'size' || name.endsWith('_size') || name.endsWith('_bytes') || name === 'bytes') && Number.isFinite(Number(value))){\n return operationLogFormatBytes(value);\n }\n if(typeof value === 'boolean') return value ? 'yes' : 'no';\n if(Array.isArray(value)){\n if(!value.length) return '';\n if(value.length <= 3 && value.every(item => item === null || ['string', 'number', 'boolean'].includes(typeof item))){\n return value.map(item => String(item)).join(', ');\n }\n return `${value.length} item(s)`;\n }\n if(typeof value === 'object'){\n const entries = Object.entries(value).filter(([, item]) => item !== null && item !== undefined && item !== '');\n if(!entries.length) return '';\n if(entries.length <= 3 && entries.every(([, item]) => ['string', 'number', 'boolean'].includes(typeof item))){\n return entries.map(([childKey, item]) => `${operationLogDetailLabel(childKey)}: ${operationLogFormatDetailValue(childKey, item)}`).join(', ');\n }\n return `${entries.length} field(s)`;\n }\n return String(value);\n }\n\n function operationLogDetailEntries(details){\n const data = details && typeof details === 'object' && !Array.isArray(details) ? details : {};\n return Object.entries(data)\n .map(([key, value]) => [key, operationLogFormatDetailValue(key, value)])\n .filter(([, value]) => value);\n }\n\n function operationLogDetailsCell(row){\n const entries = operationLogDetailEntries(row.details);\n if(!entries.length) return 'No details. ';\n // Note: Details use a compact key-value table to keep the Logs modal narrow and easy to scan.\n return `Details ${entries.map(([key, value]) => `${esc(operationLogDetailLabel(key))} ${compactCell(value, 160)} `).join('')}
`;\n }\n\n function operationLogColumns(){\n return [\n ['Time', 'operation-log-col-time'],\n ['Type', 'operation-log-col-type'],\n ['Source', 'operation-log-col-source'],\n ['Action', 'operation-log-col-action'],\n ['Torrent', 'operation-log-col-torrent'],\n ['Message', 'operation-log-col-message'],\n ];\n }\n\n function operationLogRowCells(row){\n return [\n humanDateCell(row.created_at),\n operationLogBadge(row.event_type, row.severity),\n esc(row.source || '-'),\n esc(row.action || '-'),\n operationLogTorrentCell(row),\n compactCell(row.message || '', 260),\n ];\n }\n\n function operationLogTable(rows){\n const columns = operationLogColumns();\n const showDetails = operationLogViewPrefs().showDetails;\n const head = `${columns.map(([label]) => `${esc(label)} `).join('')} `;\n const colgroup = `${columns.map(([, cls]) => ` `).join('')} `;\n const body = rows.map(row => {\n const main = `${operationLogRowCells(row).map(cell => `${cell} `).join('')} `;\n const details = showDetails ? `${operationLogDetailsCell(row)} ` : '';\n return main + details;\n }).join('');\n return `${colgroup}${head}${body}
`;\n }\n\n function renderOperationLogs(data={}){\n const box = $('operationLogsTable');\n if(!box) return;\n const rows = data.logs || [];\n const total = Number(data.total || 0);\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').dataset.ready){\n const prefs = operationLogViewPrefs();\n $('operationLogTypeFilter').innerHTML = OPERATION_LOG_TYPES.map(t => `${esc(operationLogTypeLabel(t))} `).join('');\n $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n $('operationLogTypeFilter').dataset.ready = '1';\n }\n box.innerHTML = operationLogTable(rows);\n if(!rows.length) box.innerHTML = 'No logs. No entries match current filters.
';\n const pages = Math.max(1, Math.ceil(total / operationLogsLimit));\n if($('operationLogsPager')) $('operationLogsPager').innerHTML = `Prev Page ${operationLogsPage + 1} / ${pages} ${total} logs = pages - 1 ? 'disabled' : ''}>Next `;\n $('operationLogsPrev')?.addEventListener('click', () => { operationLogsPage = Math.max(0, operationLogsPage - 1); loadOperationLogs(); });\n $('operationLogsNext')?.addEventListener('click', () => { operationLogsPage += 1; loadOperationLogs(); });\n if($('operationLogStats') && data.stats) $('operationLogStats').innerHTML = renderOperationLogStats(data.stats || {});\n fillOperationLogSettings(data.settings || data.stats?.settings || {});\n }\n\n function syncOperationLogViewControls(){\n const prefs = operationLogViewPrefs();\n if($('operationLogDefaultType')) $('operationLogDefaultType').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n if($('operationLogHideJobsDefault')) $('operationLogHideJobsDefault').checked = prefs.hideJobs;\n if($('operationLogHideJobs')) $('operationLogHideJobs').checked = prefs.hideJobs;\n if($('operationLogShowDetails')) $('operationLogShowDetails').checked = prefs.showDetails;\n if($('operationLogShowDetailsDefault')) $('operationLogShowDetailsDefault').checked = prefs.showDetails;\n if($('operationLogTypeFilter') && !$('operationLogTypeFilter').value) $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n }\n\n function saveOperationLogViewSettings(){\n const current = operationLogViewPrefs();\n const prefs = {\n defaultType: $('operationLogDefaultType')?.value || current.defaultType || '',\n hideJobs: $('operationLogHideJobsDefault') ? $('operationLogHideJobsDefault').checked : current.hideJobs,\n showDetails: $('operationLogShowDetailsDefault') ? $('operationLogShowDetailsDefault').checked : current.showDetails,\n };\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast('Log view settings saved.', 'success');\n loadOperationLogs(true);\n }\n\n function saveOperationLogDetailsPreference(){\n const prefs = {...operationLogViewPrefs(), showDetails: $('operationLogShowDetails')?.checked === true};\n saveOperationLogViewPrefs(prefs);\n if($('operationLogShowDetailsDefault')) $('operationLogShowDetailsDefault').checked = prefs.showDetails;\n renderOperationLogs(operationLogsLastData || {});\n }\n\n async function loadOperationLogs(reset=false){\n const box = $('operationLogsTable');\n if(!box) return;\n if(reset) operationLogsPage = 0;\n box.innerHTML = ' Loading logs...';\n try{\n const query = operationLogQuery() + (reset ? '&stats=1' : '');\n const data = await fetch(`/api/operation-logs?${query}`, {cache: 'no-store'}).then(r => r.json());\n if(!data.ok) throw new Error(data.error || 'Cannot load logs');\n operationLogsLastData = data;\n renderOperationLogs(data);\n }catch(e){\n box.innerHTML = `${esc(e.message)}
`;\n }\n }\n\n async function saveOperationLogSettings(){\n try{\n const data = await post('/api/operation-logs/settings', {\n job_retention_mode: $('operationLogJobRetentionMode')?.value || 'days',\n job_retention_days: Number($('operationLogJobRetentionDays')?.value || 7),\n job_retention_lines: Number($('operationLogJobRetentionLines')?.value || 2000),\n job_retention_interval_hours: Number($('operationLogJobRetentionInterval')?.value || 24),\n operation_retention_mode: $('operationLogOperationRetentionMode')?.value || 'days',\n operation_retention_days: Number($('operationLogOperationRetentionDays')?.value || 30),\n operation_retention_lines: Number($('operationLogOperationRetentionLines')?.value || 5000),\n operation_retention_interval_hours: Number($('operationLogOperationRetentionInterval')?.value || 24),\n });\n fillOperationLogSettings(data.settings || {});\n toast('Log retention saved.', 'success');\n loadOperationLogs(true);\n }catch(e){ toast(e.message, 'danger'); }\n }\n\n async function applyOperationLogRetentionNow(category='all'){\n try{\n const j = await post('/api/operation-logs/apply-retention', {category});\n const job = j.categories?.job?.deleted ?? 0;\n const operation = j.categories?.operation?.deleted ?? 0;\n const msg = category === 'job' ? `Deleted ${job} job log entries.` : category === 'operation' ? `Deleted ${operation} operation log entries.` : `Deleted ${j.deleted || 0} log entries. Jobs: ${job}, operations: ${operation}.`;\n toast(msg, 'success');\n loadOperationLogs(true);\n }catch(e){ toast(e.message, 'danger'); }\n }\n\n function bindOperationLogEvents(){\n syncOperationLogViewControls();\n $('logsModal')?.addEventListener('show.bs.modal', () => { syncOperationLogViewControls(); loadOperationLogs(true); });\n $('refreshOperationLogsBtn')?.addEventListener('click', () => loadOperationLogs(true));\n $('operationLogTypeFilter')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogHideJobs')?.addEventListener('change', () => loadOperationLogs(true));\n $('operationLogShowDetails')?.addEventListener('change', saveOperationLogDetailsPreference);\n $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogViewBtn')?.addEventListener('click', saveOperationLogViewSettings);\n $('operationLogDefaultType')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogHideJobsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogShowDetailsDefault')?.addEventListener('change', saveOperationLogViewSettings);\n $('saveOperationLogRetentionBtn')?.addEventListener('click', saveOperationLogSettings);\n $('applyOperationLogRetentionBtn')?.addEventListener('click', () => applyOperationLogRetentionNow('all'));\n $('applyJobLogRetentionBtn')?.addEventListener('click', () => applyOperationLogRetentionNow('job'));\n $('applyOnlyOperationLogRetentionBtn')?.addEventListener('click', () => applyOperationLogRetentionNow('operation'));\n $('clearOperationLogsBtn')?.addEventListener('click', async () => { if(!confirm('Clear operation logs for this profile?')) return; try{ const j = await post('/api/operation-logs/clear', {event_type: $('operationLogTypeFilter')?.value || ''}); toast(`Deleted ${j.deleted || 0} log entries.`, 'success'); loadOperationLogs(true); }catch(e){ toast(e.message, 'danger'); } });\n }\n";
diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css
index 1d55a10..54ce52a 100644
--- a/pytorrent/static/styles.css
+++ b/pytorrent/static/styles.css
@@ -5780,3 +5780,27 @@ body.compact-torrent-list .mobile-progress .torrent-progress {
border-radius: 10px;
filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.28));
}
+
+.operation-log-retention-groups {
+ display: grid;
+ gap: 0.75rem;
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
+}
+
+.operation-log-retention-card {
+ border: 1px solid var(--border-color, rgba(127, 127, 127, 0.25));
+ border-radius: 0.75rem;
+ padding: 0.75rem;
+}
+
+.operation-log-retention-card h6 {
+ margin-bottom: 0.65rem;
+}
+
+.operation-log-retention-meta {
+ color: var(--muted-color, #6c757d);
+ display: flex;
+ flex-wrap: wrap;
+ font-size: 0.82rem;
+ gap: 0.45rem 0.85rem;
+}
diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html
index e36984c..00820e0 100644
--- a/pytorrent/templates/index.html
+++ b/pytorrent/templates/index.html
@@ -303,7 +303,7 @@
-
+