From 4628ea653d06348e080c697ceba98fdee9db1df2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 3 Jun 2026 09:07:58 +0200 Subject: [PATCH] chages in logs --- pytorrent/services/operation_logs.py | 190 +++++++++++++++++++++++---- pytorrent/services/workers.py | 18 +++ pytorrent/static/js/operationLogs.js | 2 +- pytorrent/static/styles.css | 22 ++++ pytorrent/templates/index.html | 2 +- 5 files changed, 210 insertions(+), 24 deletions(-) diff --git a/pytorrent/services/operation_logs.py b/pytorrent/services/operation_logs.py index 7c52ce8..01002e5 100644 --- a/pytorrent/services/operation_logs.py +++ b/pytorrent/services/operation_logs.py @@ -4,21 +4,89 @@ import json from datetime import datetime, timedelta, timezone from typing import Any from ..db import connect, utcnow, default_user_id -from . import auth, rtorrent +from . import auth DEFAULT_SETTINGS = {"retention_mode": "days", "retention_days": 30, "retention_lines": 5000} VALID_RETENTION_MODES = {"days", "lines", "both", "manual"} +MAX_DETAIL_TEXT = 4000 +MAX_DETAIL_ITEMS = 200 def _user_id(user_id: int | None = None) -> int: return int(user_id or auth.current_user_id() or default_user_id()) +def _json_safe(value: Any, depth: int = 0) -> Any: + """Convert operation details to JSON-safe data without dropping the whole payload on one bad value.""" + if depth > 8: + return str(value)[:MAX_DETAIL_TEXT] + if value is None or isinstance(value, (bool, int, float, str)): + if isinstance(value, str) and len(value) > MAX_DETAIL_TEXT: + return value[:MAX_DETAIL_TEXT] + "..." + return value + if isinstance(value, bytes): + return f"" + if isinstance(value, (list, tuple, set)): + data = list(value) + safe = [_json_safe(item, depth + 1) for item in data[:MAX_DETAIL_ITEMS]] + if len(data) > MAX_DETAIL_ITEMS: + safe.append({"truncated_items": len(data) - MAX_DETAIL_ITEMS}) + return safe + if isinstance(value, dict): + items = list(value.items()) + safe = {str(k): _json_safe(v, depth + 1) for k, v in items[:MAX_DETAIL_ITEMS]} + if len(items) > MAX_DETAIL_ITEMS: + safe["truncated_keys"] = len(items) - MAX_DETAIL_ITEMS + return safe + return str(value)[:MAX_DETAIL_TEXT] + + def _details(value: dict | None = None) -> str: + """Serialize details defensively so partial non-serializable values do not erase the log details.""" try: - return json.dumps(value or {}, ensure_ascii=False, sort_keys=True) - except Exception: - return "{}" + return json.dumps(_json_safe(value or {}), ensure_ascii=False, sort_keys=True) + except Exception as exc: + return json.dumps({"serialization_error": str(exc), "raw_type": type(value).__name__}, ensure_ascii=False) + + +def _compact_detail_value(value: Any) -> str: + """Build a readable one-line value for the Details column while keeping full JSON separately.""" + if value in (None, ""): + return "" + if isinstance(value, (list, tuple)): + if not value: + return "" + return f"{len(value)} item(s)" + if isinstance(value, dict): + if not value: + return "" + return f"{len(value)} field(s)" + text = str(value) + return text if len(text) <= 160 else text[:157] + "..." + + +def _details_summary(details: dict) -> str: + """Summarize important detail fields without hiding the full details_json payload.""" + priority = [ + "status", "job_id", "attempt", "attempts", "count", "hash_count", "action", + "source", "source_label", "directory", "label", "target_path", "remove_data", + "move_data", "keep_seeding", "error", "error_count", "result_count", + ] + parts: list[str] = [] + for key in priority: + if key in details: + value = _compact_detail_value(details.get(key)) + if value: + parts.append(f"{key}: {value}") + for key, raw in details.items(): + if key in priority: + continue + value = _compact_detail_value(raw) + if value: + parts.append(f"{key}: {value}") + if len(parts) >= 10: + break + return ", ".join(parts) def _row_to_public(row: dict) -> dict: @@ -27,7 +95,7 @@ def _row_to_public(row: dict) -> dict: item["details"] = json.loads(item.get("details_json") or "{}") except Exception: item["details"] = {} - item["details_h"] = ", ".join(f"{k}: {v}" for k, v in item["details"].items() if v not in (None, "")) + item["details_h"] = _details_summary(item["details"]) return item @@ -74,6 +142,7 @@ def save_settings(profile_id: int, data: dict, user_id: int | None = None) -> di 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.""" now = utcnow() user_id = _user_id(user_id) with connect() as conn: @@ -87,48 +156,125 @@ def record(profile_id: int | None, event_type: str, message: str, *, severity: s return int(cur.lastrowid) +def _job_event_type(status: str) -> str: + """Map worker states to explicit operation log event types without changing old done/failed names.""" + return { + "queued": "job_queued", + "started": "job_started", + "done": "job_done", + "failed": "job_failed", + "retry": "job_retry", + "cancelled": "job_cancelled", + "timeout": "job_timeout", + "resubmitted": "job_resubmitted", + "forced": "job_forced", + }.get(str(status), "job_event") + + +def _job_severity(status: str) -> str: + """Use severity consistently for filtering and badge rendering.""" + if status in {"failed", "timeout"}: + return "danger" + if status in {"retry", "resubmitted", "cancelled", "forced"}: + return "warning" + return "info" + + +def _job_action_label(action: str) -> str: + """Return a stable human-readable action label for log messages.""" + labels = { + "add_magnet": "Magnet link", + "add_torrent_raw": "Torrent file", + "set_label": "Set label", + "set_ratio_group": "Set ratio group", + "set_limits": "Set speed limits", + "smart_queue_check": "Smart Queue check", + } + return labels.get(str(action or ""), str(action or "job")) + + +def _result_summary(result: dict) -> dict: + """Extract compact result counters while preserving full result in details.""" + result = result or {} + results = result.get("results") if isinstance(result.get("results"), list) else [] + errors = result.get("errors") if isinstance(result.get("errors"), list) else [] + ignored_errors = result.get("ignored_errors") if isinstance(result.get("ignored_errors"), list) else [] + return { + "result_count": len(results) if results is not None else result.get("count"), + "error_count": len(errors or []) + len(ignored_errors or []), + } + + def record_job_event(profile_id: int, action: str, status: str, payload: dict | None, result: dict | None = None, error: str = "", job_id: str | None = None, user_id: int | None = None) -> None: + """Record queued, running and terminal job states with per-torrent context when available.""" payload = payload or {} result = result or {} hashes = payload.get("hashes") or [] ctx = payload.get("job_context") or {} items = ctx.get("items") or [] by_hash = {str(item.get("hash")): item for item in items if item} - event_type = "job_done" if status == "done" else "job_failed" if status == "failed" else "job_started" - severity = "danger" if status == "failed" else "info" + event_type = _job_event_type(str(status)) + severity = _job_severity(str(status)) + context_source = str(ctx.get("source") or payload.get("source") or "user") + source_label = str(ctx.get("rule_name") or ctx.get("source") or context_source) + source = "job" + base_details = { + "job_id": job_id, + "status": status, + "source": context_source, + "source_label": source_label, + "directory": payload.get("directory"), + "label": payload.get("label"), + "target_path": ctx.get("target_path") or payload.get("path"), + "remove_data": ctx.get("remove_data") or payload.get("remove_data"), + "move_data": ctx.get("move_data") or payload.get("move_data"), + "keep_seeding": payload.get("keep_seeding"), + "hash_count": len(hashes), + "error": error, + "result": result, + **_result_summary(result), + } if action in {"add_magnet", "add_torrent_raw"}: name = str(payload.get("name") or payload.get("filename") or payload.get("uri") or "torrent")[:300] - # Note: Keep the internal action name stable, but show a user-facing label instead of raw worker identifiers. - source_label = "Torrent file" if action == "add_torrent_raw" else "Magnet link" - status_label = {"started": "queued", "done": "added", "failed": "failed"}.get(str(status), str(status)) - msg = f"{source_label} {status_label}: {name}" - record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "directory": payload.get("directory"), "label": payload.get("label"), "error": error, "result": result}, user_id=user_id) + status_label = {"queued": "queued", "started": "started", "done": "added", "failed": "failed", "retry": "retry scheduled", "cancelled": "cancelled"}.get(str(status), str(status)) + msg = f"{_job_action_label(action)} {status_label}: {name}" + record(profile_id, "torrent_added" if status == "done" else event_type, msg, severity=severity, source=source, action=action, details=base_details, user_id=user_id) return if not hashes: - record(profile_id, event_type, f"{action} {status}", severity=severity, source="job", action=action, details={"job_id": job_id, "status": status, "error": error, "result": result}, user_id=user_id) + record(profile_id, event_type, f"{_job_action_label(action)} {status}", severity=severity, source=source, action=action, details=base_details, user_id=user_id) return for h in hashes: item = by_hash.get(str(h)) or {} name = str(item.get("name") or h) - record(profile_id, "torrent_removed" if action == "remove" and status == "done" else event_type, f"{action} {status}: {name}", severity=severity, source="job", torrent_hash=str(h), torrent_name=name, action=action, details={"job_id": job_id, "status": status, "error": error, "result": result, "target_path": ctx.get("target_path"), "remove_data": ctx.get("remove_data")}, user_id=user_id) + row_details = {**base_details, "item": item} + record(profile_id, "torrent_removed" if action == "remove" and status == "done" else event_type, f"{_job_action_label(action)} {status}: {name}", severity=severity, source=source, torrent_hash=str(h), torrent_name=name, action=action, details=row_details, user_id=user_id) + + +def record_worker_event(profile_id: int, action: str, status: str, message: str, *, payload: dict | None = None, job_id: str | None = None, user_id: int | None = None, error: str = "", details: dict | None = None) -> None: + """Log worker-only lifecycle events that do not execute the normal job action path.""" + payload = payload or {} + merged = {"job_id": job_id, "status": status, "error": error, "payload": payload, **(details or {})} + record(profile_id, _job_event_type(status), message, severity=_job_severity(status), source="worker", action=action, details=merged, user_id=user_id) def record_cache_diff(profile_id: int, added: list[dict], removed: list[str], updated: list[dict], old_rows: dict[str, dict]) -> None: + """Record torrent cache changes detected by the poller without depending on manual jobs.""" for row in added or []: - record(profile_id, "torrent_added", f"Torrent added: {row.get('name') or row.get('hash')}", source="poller", torrent_hash=row.get("hash"), torrent_name=row.get("name"), details={"size": row.get("size"), "path": row.get("path"), "label": row.get("label")}) + record(profile_id, "torrent_added", f"Torrent added: {row.get('name') or row.get('hash')}", source="poller", torrent_hash=row.get("hash"), torrent_name=row.get("name"), details={"size": row.get("size"), "path": row.get("path"), "label": row.get("label"), "tracker": row.get("tracker")}) for h in removed or []: old = old_rows.get(str(h)) or {} - record(profile_id, "torrent_removed", f"Torrent removed: {old.get('name') or h}", source="poller", torrent_hash=str(h), torrent_name=old.get("name"), details={"path": old.get("path"), "label": old.get("label")}) + record(profile_id, "torrent_removed", f"Torrent removed: {old.get('name') or h}", source="poller", torrent_hash=str(h), torrent_name=old.get("name"), details={"path": old.get("path"), "label": old.get("label"), "tracker": old.get("tracker")}) for patch in updated or []: h = str(patch.get("hash") or "") old = old_rows.get(h) or {} was_complete = bool(old.get("complete")) or float(old.get("progress") or 0) >= 100 is_complete = bool(patch.get("complete", old.get("complete"))) or float(patch.get("progress", old.get("progress") or 0) or 0) >= 100 if h and not was_complete and is_complete: - record(profile_id, "torrent_completed", f"Torrent completed: {old.get('name') or h}", source="poller", torrent_hash=h, torrent_name=old.get("name"), details={"ratio": patch.get("ratio", old.get("ratio")), "size": old.get("size"), "path": old.get("path")}) + record(profile_id, "torrent_completed", f"Torrent completed: {old.get('name') or h}", source="poller", torrent_hash=h, torrent_name=old.get("name"), details={"ratio": patch.get("ratio", old.get("ratio")), "size": old.get("size"), "path": old.get("path"), "label": old.get("label"), "tracker": old.get("tracker")}) def list_logs(profile_id: int, *, limit: int = 200, offset: int = 0, event_type: str = "", q: str = "", hide_jobs: bool = False) -> dict: + """Return operation logs with searchable messages, torrents, actions and detail JSON.""" limit = max(1, min(int(limit or 200), 1000)) offset = max(0, int(offset or 0)) where = ["(profile_id=? OR profile_id IS NULL)"] @@ -137,12 +283,11 @@ 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: - # Note: Job-originated rows include torrent_added/torrent_removed events, so source is the reliable filter. - where.append("COALESCE(source, '') <> 'job'") + where.append("COALESCE(source, '') <> 'job' AND event_type NOT LIKE 'job_%'") if q: - where.append("(message LIKE ? OR torrent_name LIKE ? OR torrent_hash LIKE ? OR action LIKE ?)") + where.append("(message LIKE ? OR torrent_name LIKE ? OR torrent_hash LIKE ? OR action LIKE ? OR details_json LIKE ?)") like = f"%{q}%" - params.extend([like, like, like, like]) + params.extend([like, like, like, like, like]) sql_where = " WHERE " + " AND ".join(where) with connect() as conn: rows = conn.execute(f"SELECT * FROM operation_logs{sql_where} ORDER BY id DESC LIMIT ? OFFSET ?", (*params, limit, offset)).fetchall() @@ -161,7 +306,6 @@ 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" if mode == "manual": @@ -172,6 +316,7 @@ def retention_label(settings: dict) -> str: 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" + def clear(profile_id: int, *, event_type: str = "") -> int: where = ["(profile_id=? OR profile_id IS NULL)"] params: list[Any] = [int(profile_id or 0)] @@ -184,6 +329,7 @@ def clear(profile_id: int, *, event_type: str = "") -> int: 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" deleted_days = 0 diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py index bcf028e..69dbbc1 100644 --- a/pytorrent/services/workers.py +++ b/pytorrent/services/workers.py @@ -200,6 +200,8 @@ def enqueue(action_name: str, profile_id: int, payload: dict, user_id: int | Non "INSERT INTO jobs(id,user_id,profile_id,action,payload_json,status,attempts,max_attempts,progress_total,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)", (job_id, user_id, profile_id, action_name, json.dumps(payload), "pending", 0, max_attempts, progress_total, now, now), ) + # Note: Queued jobs are now written to operation logs so work is visible before a worker starts it. + operation_logs.record_job_event(profile_id, action_name, "queued", payload, job_id=job_id, user_id=user_id) _emit("job_update", {"id": job_id, "action": action_name, "profile_id": profile_id, "status": "pending"}) _submit_job(job_id, action_name) return job_id @@ -315,6 +317,8 @@ def _run(job_id: str): profile = get_profile(int(job["profile_id"]), int(job["user_id"])) if not profile: _set_job(job_id, "failed", "rTorrent profile does not exist", finished=True) + # Note: Profile lookup failures used to appear only in the job queue; they are now persisted in operation logs too. + operation_logs.record_worker_event(int(job.get("profile_id") or 0), str(job.get("action") or ""), "failed", "Job failed: rTorrent profile does not exist", job_id=job_id, user_id=int(job.get("user_id") or 0), error="profile not found") _emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": "failed", "error": "profile not found"}) return profile_id = int(profile["id"]) @@ -362,6 +366,9 @@ def _run(job_id: str): _set_job(job_id, status, str(exc), finished=(status == "failed")) if status == "failed": operation_logs.record_job_event(int(job.get("profile_id") or 0), job.get("action"), "failed", payload, error=str(exc), job_id=job_id, user_id=int(job.get("user_id") or 0)) + else: + # Note: Retried attempts are logged explicitly so transient failures are not lost between final states. + operation_logs.record_job_event(int(job.get("profile_id") or 0), job.get("action"), "retry", payload, error=str(exc), job_id=job_id, user_id=int(job.get("user_id") or 0)) _emit("operation_failed", {"job_id": job_id, "action": job.get("action"), "profile_id": job.get("profile_id"), "hashes": payload.get("hashes") or [], "error": str(exc), **_job_event_meta(payload)}) _emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": status, "error": str(exc), "attempts": attempts}) if status == "pending": @@ -408,6 +415,8 @@ def _timeout_running_jobs() -> None: continue message = f"Watchdog timeout after {_job_timeout_seconds(profile, row)} seconds" _set_job(row["id"], "failed", message, finished=True) + # Note: Watchdog timeouts are stored in operation logs because no normal worker exception may be raised. + operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "timeout", message, job_id=row["id"], user_id=int(row.get("user_id") or 0), error=message) _emit("operation_failed", {"job_id": row["id"], "action": row.get("action"), "profile_id": row.get("profile_id"), "hashes": [], "error": message, "source": "watchdog"}) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "failed", "error": message}) @@ -435,6 +444,8 @@ def _resubmit_interrupted_running_jobs() -> None: ("Resuming interrupted job from last checkpoint", utcnow(), row["id"]), ) if int(cur.rowcount or 0): + # Note: Interrupted jobs returned to the queue are logged so restart recovery is auditable. + operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Interrupted job resubmitted from checkpoint", job_id=row["id"], user_id=int(row.get("user_id") or 0)) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "resumed": True}) _submit_job(row["id"], row.get("action")) @@ -456,6 +467,8 @@ def _resubmit_stale_pending_jobs() -> None: continue with connect() as conn: conn.execute("UPDATE jobs SET error=?, updated_at=? WHERE id=? AND status='pending'", ("Watchdog resubmitted stale pending job", utcnow(), row["id"])) + # Note: Stale pending resubmits are logged to explain duplicated queue attempts after watchdog recovery. + operation_logs.record_worker_event(int(row.get("profile_id") or 0), str(row.get("action") or ""), "resubmitted", "Stale pending job resubmitted by watchdog", job_id=row["id"], user_id=int(row.get("user_id") or 0)) _emit("job_update", {"id": row["id"], "profile_id": row.get("profile_id"), "status": "pending", "watchdog": True}) _submit_job(row["id"], row.get("action")) @@ -561,6 +574,8 @@ def cancel_job(job_id: str) -> bool: return False # Note: Emergency cancel is useful only for unfinished jobs; failed/done entries stay available for retry or log cleanup. _set_job(job_id, "cancelled", finished=True) + payload = _job_payload(row) + operation_logs.record_job_event(int(row.get("profile_id") or 0), row.get("action"), "cancelled", payload, error="Cancelled by user", job_id=job_id, user_id=int(row.get("user_id") or 0)) _emit("job_update", {"id": job_id, "profile_id": row.get("profile_id"), "status": "cancelled"}) return True @@ -597,6 +612,7 @@ def force_job(job_id: str) -> bool: payload['priority_job'] = True with connect() as conn: conn.execute("UPDATE jobs SET payload_json=?, updated_at=? WHERE id=?", (json.dumps(payload), utcnow(), job_id)) + operation_logs.record_job_event(int(row.get('profile_id') or 0), row.get('action'), 'forced', payload, job_id=job_id, user_id=int(row.get('user_id') or 0)) _emit('job_update', {'id': job_id, 'profile_id': row.get('profile_id'), 'status': 'pending', 'forced': True}) _submit_job(job_id, row.get('action')) return True @@ -607,6 +623,8 @@ def retry_job(job_id: str) -> bool: return False with connect() as conn: conn.execute("UPDATE jobs SET status='pending', error='', finished_at=NULL, state_json=NULL, progress_current=0, heartbeat_at=NULL, updated_at=? WHERE id=?", (utcnow(), job_id)) + payload = _job_payload(row) + operation_logs.record_job_event(int(row.get("profile_id") or 0), row.get("action"), "retry", payload, job_id=job_id, user_id=int(row.get("user_id") or 0)) _emit("job_update", {"id": job_id, "profile_id": row.get("profile_id"), "status": "pending"}) _submit_job(job_id, row.get("action")) return True diff --git a/pytorrent/static/js/operationLogs.js b/pytorrent/static/js/operationLogs.js index 3ffdc02..f9abb4c 100644 --- a/pytorrent/static/js/operationLogs.js +++ b/pytorrent/static/js/operationLogs.js @@ -1 +1 @@ -export const operationLogsSource = " let operationLogsPage = 0;\n const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = 'pytorrent.operationLogView.v1';\n const OPERATION_LOG_TYPES = ['', 'torrent_added', 'torrent_removed', 'torrent_completed', 'job_started', 'job_done', 'job_failed'];\n function readOperationLogViewPrefs(){\n try{ return JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || '{}') || {}; }\n catch(e){ return {}; }\n }\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {defaultType: String(prefs.defaultType ?? ''), hideJobs: prefs.hideJobs !== false};\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 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\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 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 = responsiveTable(['Time','Type','Source','Action','Torrent','Message','Details'], rows.map(r => [\n humanDateCell(r.created_at),\n operationLogBadge(r.event_type, r.severity),\n esc(r.source || '-'),\n esc(r.action || '-'),\n operationLogTorrentCell(r),\n compactCell(r.message || '', 260),\n compactCell(r.details_h || '', 220),\n ]), 'operation-log-table');\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($('operationLogTypeFilter') && !$('operationLogTypeFilter').value) $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n }\n\n function saveOperationLogViewSettings(){\n const prefs = {defaultType: $('operationLogDefaultType')?.value || '', hideJobs: $('operationLogHideJobsDefault')?.checked !== false};\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast('Log view settings saved.', 'success');\n loadOperationLogs(true);\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()}`).then(r => r.json());\n if(!data.ok) throw new Error(data.error || 'Cannot load logs');\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 $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogViewBtn')?.addEventListener('click', saveOperationLogViewSettings);\n $('operationLogDefaultType')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogHideJobsDefault')?.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 const operationLogsLimit = 200;\n const OPERATION_LOG_VIEW_STORAGE_KEY = 'pytorrent.operationLogView.v1';\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 function readOperationLogViewPrefs(){\n try{ return JSON.parse(localStorage.getItem(OPERATION_LOG_VIEW_STORAGE_KEY) || '{}') || {}; }\n catch(e){ return {}; }\n }\n function saveOperationLogViewPrefs(prefs){ localStorage.setItem(OPERATION_LOG_VIEW_STORAGE_KEY, JSON.stringify(prefs)); }\n function operationLogViewPrefs(){\n const prefs = readOperationLogViewPrefs();\n return {defaultType: String(prefs.defaultType ?? ''), hideJobs: prefs.hideJobs !== false};\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 operationLogDetailsCell(row){\n const summary = row.details_h || '';\n const full = JSON.stringify(row.details || {}, null, 2);\n if(!summary && (!full || full === '{}')) return '';\n // Note: Details are expandable so long JSON results stay complete without widening the table.\n return `
${summary ? esc(summary) : 'Full details'}
${esc(full)}
`;\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 = responsiveTable(['Time','Type','Source','Action','Torrent','Message','Details'], rows.map(r => [\n humanDateCell(r.created_at),\n operationLogBadge(r.event_type, r.severity),\n esc(r.source || '-'),\n esc(r.action || '-'),\n operationLogTorrentCell(r),\n compactCell(r.message || '', 260),\n operationLogDetailsCell(r),\n ]), 'operation-log-table');\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($('operationLogTypeFilter') && !$('operationLogTypeFilter').value) $('operationLogTypeFilter').value = OPERATION_LOG_TYPES.includes(prefs.defaultType) ? prefs.defaultType : '';\n }\n\n function saveOperationLogViewSettings(){\n const prefs = {defaultType: $('operationLogDefaultType')?.value || '', hideJobs: $('operationLogHideJobsDefault')?.checked !== false};\n saveOperationLogViewPrefs(prefs);\n syncOperationLogViewControls();\n toast('Log view settings saved.', 'success');\n loadOperationLogs(true);\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 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 $('operationLogSearch')?.addEventListener('input', debounce(() => loadOperationLogs(true), 300));\n $('saveOperationLogViewBtn')?.addEventListener('click', saveOperationLogViewSettings);\n $('operationLogDefaultType')?.addEventListener('change', saveOperationLogViewSettings);\n $('operationLogHideJobsDefault')?.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"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 4ad3d1e..c8a6d18 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -4656,6 +4656,28 @@ body, vertical-align: top; } +.operation-log-details { + max-width: 24rem; +} + +.operation-log-details summary { + cursor: pointer; + overflow-wrap: anywhere; +} + +.operation-log-details pre { + background: var(--bs-tertiary-bg); + border: 1px solid var(--bs-border-color); + border-radius: .5rem; + color: var(--bs-body-color); + font-size: .75rem; + margin: .5rem 0 0; + max-height: 18rem; + overflow: auto; + padding: .65rem; + white-space: pre-wrap; +} + @media (max-width: 760px) { .operation-log-type-filter, .operation-log-search { diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 028ea72..25eee33 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -298,7 +298,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.
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.
Log statistics
Profile-scoped log counts and cleanup overview.
Loading statistics...
+
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...
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.