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 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 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]) => ``).join('')}
${esc(operationLogDetailLabel(key))}${compactCell(value, 160)}
`;\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 => ``).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 = `Page ${operationLogsPage + 1} / ${pages} ${total} logs`;\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]) => ``).join('')}
${esc(operationLogDetailLabel(key))}${compactCell(value, 160)}
`;\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 => ``).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 = `Page ${operationLogsPage + 1} / ${pages} ${total} logs`;\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 @@
Appearance
Theme, typography and interface scale. Torrent view preferences also remember the selected filter, sorting and the height of the General / Files / Trackers panel.
View state is saved automatically in the database: current torrent filter, last sort column and direction, visible columns, and details panel height.
Browser title
Controls what is shown in the browser tab.
Tracker icons
Visual helper for tracker filters in the sidebar.
Notifications
Toast notifications from automatic systems.
Disk monitor
Choose what the footer disk bar should represent and add extra storage paths.
{% if prefs and prefs.disk_monitor_owner_label %}Owner: {{ prefs.disk_monitor_owner_label }}{% endif %}
Progress source
Monitored paths
The footer tooltip always shows details for available paths; this setting only decides which value drives the visible progress bar.
Port checker
Incoming connection test, separate from visual preferences.
disabled
Uses YouGetSignal first. Manual check bypasses the 6h cache.
Peers
Optional peer table helpers.
Easter egg
Optional visual easter egg for loading states and occasional button clicks. Disabled by default.
Changes apply immediately where possible; initial startup loader uses them after reload.
Job scheduling
These settings are stored per active rTorrent profile. Light jobs are control actions such as start, stop, pause, resume, labels, ratio assignment, reannounce and speed limits. Heavy jobs are long or destructive actions such as move, remove and adding torrents.
-
Operation log retention
Manage operation log retention without changing torrent data.
Default log view
Controls the default category and job log visibility used by the Logs modal. Queued, retry, timeout and recovery events are included.
Log statistics
Profile-scoped log counts and cleanup overview.
Loading statistics...
+
Log retention
Job logs
Operation / torrent logs
Default log view
Controls the default category and job log visibility used by the Logs modal. Queued, retry, timeout and recovery events are included.
Log statistics
Profile-scoped log counts and cleanup overview.
Loading statistics...
Users
Manage optional pyTorrent users. Empty profile means all profiles. R/O blocks rTorrent-changing actions; Full allows them.
Labels
Create reusable labels and remove labels that are no longer needed.