logs_commit1
This commit is contained in:
@@ -457,6 +457,35 @@ CREATE TABLE IF NOT EXISTS tracker_summary_cache (
|
|||||||
);
|
);
|
||||||
CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch);
|
CREATE INDEX IF NOT EXISTS idx_tracker_summary_cache_profile ON tracker_summary_cache(profile_id, updated_epoch);
|
||||||
|
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operation_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
profile_id INTEGER,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
severity TEXT DEFAULT 'info',
|
||||||
|
source TEXT DEFAULT 'system',
|
||||||
|
torrent_hash TEXT,
|
||||||
|
torrent_name TEXT,
|
||||||
|
action TEXT,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
details_json TEXT,
|
||||||
|
created_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS operation_log_settings (
|
||||||
|
user_id INTEGER NOT NULL,
|
||||||
|
profile_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
retention_mode TEXT DEFAULT 'days',
|
||||||
|
retention_days INTEGER DEFAULT 30,
|
||||||
|
retention_lines INTEGER DEFAULT 5000,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(user_id, profile_id)
|
||||||
|
);
|
||||||
CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
|
CREATE TABLE IF NOT EXISTS tracker_favicon_cache (
|
||||||
domain TEXT PRIMARY KEY,
|
domain TEXT PRIMARY KEY,
|
||||||
source_url TEXT,
|
source_url TEXT,
|
||||||
@@ -579,6 +608,11 @@ MIGRATIONS = [
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)",
|
"CREATE INDEX IF NOT EXISTS idx_automation_history_user_profile_created ON automation_history(user_id, profile_id, created_at)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)",
|
"CREATE INDEX IF NOT EXISTS idx_user_preferences_user ON user_preferences(user_id)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE)",
|
"CREATE INDEX IF NOT EXISTS idx_rtorrent_profiles_user_default_name ON rtorrent_profiles(user_id, is_default, name COLLATE NOCASE)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS operation_logs (id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL, profile_id INTEGER, event_type TEXT NOT NULL, severity TEXT DEFAULT 'info', source TEXT DEFAULT 'system', torrent_hash TEXT, torrent_name TEXT, action TEXT, message TEXT NOT NULL, details_json TEXT, created_at TEXT NOT NULL)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_operation_logs_event_type ON operation_logs(event_type, created_at)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS operation_log_settings (user_id INTEGER NOT NULL, profile_id INTEGER NOT NULL DEFAULT 0, retention_mode TEXT DEFAULT 'days', retention_days INTEGER DEFAULT 30, retention_lines INTEGER DEFAULT 5000, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, PRIMARY KEY(user_id, profile_id))",
|
||||||
]
|
]
|
||||||
|
|
||||||
POST_MIGRATION_INDEXES = [
|
POST_MIGRATION_INDEXES = [
|
||||||
@@ -589,6 +623,8 @@ POST_MIGRATION_INDEXES = [
|
|||||||
"CREATE INDEX IF NOT EXISTS idx_jobs_status_heartbeat ON jobs(status, heartbeat_at)",
|
"CREATE INDEX IF NOT EXISTS idx_jobs_status_heartbeat ON jobs(status, heartbeat_at)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_jobs_user_profile_created ON jobs(user_id, profile_id, created_at)",
|
"CREATE INDEX IF NOT EXISTS idx_jobs_user_profile_created ON jobs(user_id, profile_id, created_at)",
|
||||||
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)",
|
"CREATE INDEX IF NOT EXISTS idx_jobs_profile_status_active ON jobs(profile_id, status)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_operation_logs_profile_created ON operation_logs(profile_id, created_at)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_operation_logs_user_profile_created ON operation_logs(user_id, profile_id, created_at)",
|
||||||
]
|
]
|
||||||
|
|
||||||
def utcnow() -> str:
|
def utcnow() -> str:
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ from . import automations as _automations_routes
|
|||||||
from . import smart_queue as _smart_queue_routes
|
from . import smart_queue as _smart_queue_routes
|
||||||
from . import system as _system_routes
|
from . import system as _system_routes
|
||||||
from . import backup as _backup_routes
|
from . import backup as _backup_routes
|
||||||
|
from . import operation_logs as _operation_logs_routes
|
||||||
|
|
||||||
__all__ = ["bp"]
|
__all__ = ["bp"]
|
||||||
|
|||||||
56
pytorrent/routes/operation_logs.py
Normal file
56
pytorrent/routes/operation_logs.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from ._shared import *
|
||||||
|
from ..services import operation_logs
|
||||||
|
|
||||||
|
|
||||||
|
def _active_profile_or_400():
|
||||||
|
profile = preferences.active_profile()
|
||||||
|
if not profile:
|
||||||
|
return None
|
||||||
|
return profile
|
||||||
|
|
||||||
|
|
||||||
|
@bp.get("/operation-logs")
|
||||||
|
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),
|
||||||
|
offset=int(request.args.get("offset") or 0),
|
||||||
|
event_type=str(request.args.get("type") or "").strip(),
|
||||||
|
q=str(request.args.get("q") or "").strip(),
|
||||||
|
)
|
||||||
|
data["stats"] = operation_logs.stats(int(profile["id"]))
|
||||||
|
data["settings"] = data["stats"].get("settings")
|
||||||
|
return ok(data)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/operation-logs/settings")
|
||||||
|
def operation_logs_settings_save():
|
||||||
|
profile = _active_profile_or_400()
|
||||||
|
if not profile:
|
||||||
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
|
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})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/operation-logs/clear")
|
||||||
|
def operation_logs_clear():
|
||||||
|
profile = _active_profile_or_400()
|
||||||
|
if not profile:
|
||||||
|
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||||
|
event_type = str((request.get_json(silent=True) or {}).get("event_type") or "").strip()
|
||||||
|
return ok({"deleted": operation_logs.clear(int(profile["id"]), event_type=event_type)})
|
||||||
|
|
||||||
|
|
||||||
|
@bp.post("/operation-logs/apply-retention")
|
||||||
|
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"])))
|
||||||
186
pytorrent/services/operation_logs.py
Normal file
186
pytorrent/services/operation_logs.py
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from typing import Any
|
||||||
|
from ..db import connect, utcnow, default_user_id
|
||||||
|
from . import auth, rtorrent
|
||||||
|
|
||||||
|
DEFAULT_SETTINGS = {"retention_mode": "days", "retention_days": 30, "retention_lines": 5000}
|
||||||
|
VALID_RETENTION_MODES = {"days", "lines", "both", "manual"}
|
||||||
|
|
||||||
|
|
||||||
|
def _user_id(user_id: int | None = None) -> int:
|
||||||
|
return int(user_id or auth.current_user_id() or default_user_id())
|
||||||
|
|
||||||
|
|
||||||
|
def _details(value: dict | None = None) -> str:
|
||||||
|
try:
|
||||||
|
return json.dumps(value or {}, ensure_ascii=False, sort_keys=True)
|
||||||
|
except Exception:
|
||||||
|
return "{}"
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_public(row: dict) -> dict:
|
||||||
|
item = dict(row)
|
||||||
|
try:
|
||||||
|
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, ""))
|
||||||
|
return item
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
with connect() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM operation_log_settings WHERE user_id=? AND profile_id=?",
|
||||||
|
(user_id, profile_id),
|
||||||
|
).fetchone()
|
||||||
|
if not row:
|
||||||
|
return {"user_id": user_id, "profile_id": profile_id, **DEFAULT_SETTINGS}
|
||||||
|
data = {**DEFAULT_SETTINGS, **dict(row)}
|
||||||
|
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"]))
|
||||||
|
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()
|
||||||
|
with connect() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO operation_log_settings(user_id, profile_id, retention_mode, retention_days, retention_lines, 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,
|
||||||
|
updated_at=excluded.updated_at
|
||||||
|
""",
|
||||||
|
(user_id, profile_id, mode, days, lines, 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:
|
||||||
|
now = utcnow()
|
||||||
|
user_id = _user_id(user_id)
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
return int(cur.lastrowid)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
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"
|
||||||
|
if action in {"add_magnet", "add_torrent_raw"}:
|
||||||
|
name = str(payload.get("name") or payload.get("filename") or payload.get("uri") or "torrent")[:300]
|
||||||
|
msg = f"{action} {status}: {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)
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def record_cache_diff(profile_id: int, added: list[dict], removed: list[str], updated: list[dict], old_rows: dict[str, dict]) -> None:
|
||||||
|
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")})
|
||||||
|
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")})
|
||||||
|
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")})
|
||||||
|
|
||||||
|
|
||||||
|
def list_logs(profile_id: int, *, limit: int = 200, offset: int = 0, event_type: str = "", q: str = "") -> dict:
|
||||||
|
limit = max(1, min(int(limit or 200), 1000))
|
||||||
|
offset = max(0, int(offset or 0))
|
||||||
|
where = ["(profile_id=? OR profile_id IS NULL)"]
|
||||||
|
params: list[Any] = [int(profile_id or 0)]
|
||||||
|
if event_type:
|
||||||
|
where.append("event_type=?")
|
||||||
|
params.append(event_type)
|
||||||
|
if q:
|
||||||
|
where.append("(message LIKE ? OR torrent_name LIKE ? OR torrent_hash LIKE ? OR action LIKE ?)")
|
||||||
|
like = f"%{q}%"
|
||||||
|
params.extend([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()
|
||||||
|
total = conn.execute(f"SELECT COUNT(*) AS n FROM operation_logs{sql_where}", tuple(params)).fetchone()["n"]
|
||||||
|
return {"logs": [_row_to_public(r) for r in rows], "total": int(total or 0), "limit": limit, "offset": offset}
|
||||||
|
|
||||||
|
|
||||||
|
def stats(profile_id: int) -> dict:
|
||||||
|
profile_id = int(profile_id or 0)
|
||||||
|
with connect() as conn:
|
||||||
|
total = conn.execute("SELECT COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL", (profile_id,)).fetchone()["n"]
|
||||||
|
by_type = conn.execute("SELECT event_type, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY event_type ORDER BY n DESC LIMIT 12", (profile_id,)).fetchall()
|
||||||
|
by_day = conn.execute("SELECT substr(created_at,1,10) AS bucket, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY bucket ORDER BY bucket DESC LIMIT 14", (profile_id,)).fetchall()
|
||||||
|
by_month = conn.execute("SELECT substr(created_at,1,7) AS bucket, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY bucket ORDER BY bucket DESC LIMIT 12", (profile_id,)).fetchall()
|
||||||
|
top_actions = conn.execute("SELECT COALESCE(action, event_type) AS action, COUNT(*) AS n FROM operation_logs WHERE profile_id=? OR profile_id IS NULL GROUP BY COALESCE(action, event_type) ORDER BY n DESC LIMIT 12", (profile_id,)).fetchall()
|
||||||
|
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 clear(profile_id: int, *, event_type: str = "") -> int:
|
||||||
|
where = ["(profile_id=? OR profile_id IS NULL)"]
|
||||||
|
params: list[Any] = [int(profile_id or 0)]
|
||||||
|
if event_type:
|
||||||
|
where.append("event_type=?")
|
||||||
|
params.append(event_type)
|
||||||
|
with connect() as conn:
|
||||||
|
cur = conn.execute("DELETE FROM operation_logs WHERE " + " AND ".join(where), tuple(params))
|
||||||
|
return int(cur.rowcount or 0)
|
||||||
|
|
||||||
|
|
||||||
|
def apply_retention(profile_id: int, user_id: int | None = None) -> dict:
|
||||||
|
settings = get_settings(profile_id, user_id)
|
||||||
|
mode = settings.get("retention_mode") or "manual"
|
||||||
|
deleted_days = 0
|
||||||
|
deleted_lines = 0
|
||||||
|
with connect() as conn:
|
||||||
|
if mode in {"days", "both"}:
|
||||||
|
cutoff = (datetime.now(timezone.utc) - timedelta(days=int(settings["retention_days"]))).isoformat(timespec="seconds")
|
||||||
|
cur = conn.execute("DELETE FROM operation_logs WHERE (profile_id=? OR profile_id IS NULL) AND created_at<?", (int(profile_id or 0), cutoff))
|
||||||
|
deleted_days = int(cur.rowcount or 0)
|
||||||
|
if mode in {"lines", "both"}:
|
||||||
|
keep = int(settings["retention_lines"])
|
||||||
|
ids = conn.execute("SELECT id FROM operation_logs WHERE profile_id=? OR profile_id IS NULL ORDER BY id DESC LIMIT -1 OFFSET ?", (int(profile_id or 0), keep)).fetchall()
|
||||||
|
if ids:
|
||||||
|
placeholders = ",".join("?" for _ in ids)
|
||||||
|
cur = conn.execute(f"DELETE FROM operation_logs WHERE id IN ({placeholders})", tuple(r["id"] for r in ids))
|
||||||
|
deleted_lines = int(cur.rowcount or 0)
|
||||||
|
return {"deleted_days": deleted_days, "deleted_lines": deleted_lines, "deleted": deleted_days + deleted_lines, "settings": settings}
|
||||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from threading import RLock
|
from threading import RLock
|
||||||
from time import time
|
from time import time
|
||||||
from . import rtorrent
|
from . import rtorrent, operation_logs
|
||||||
|
|
||||||
_VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"}
|
_VOLATILE = {"down_rate", "down_rate_h", "up_rate", "up_rate_h", "progress", "completed_bytes", "peers", "seeds", "ratio", "state", "status", "message", "down_total", "down_total_h", "to_download", "to_download_h", "up_total", "up_total_h"}
|
||||||
|
|
||||||
@@ -58,6 +58,8 @@ class TorrentCache:
|
|||||||
self._data[profile_id] = fresh
|
self._data[profile_id] = fresh
|
||||||
self._errors[profile_id] = ""
|
self._errors[profile_id] = ""
|
||||||
self._updated_at[profile_id] = time()
|
self._updated_at[profile_id] = time()
|
||||||
|
if old:
|
||||||
|
operation_logs.record_cache_diff(profile_id, added, removed, updated, old)
|
||||||
return {"ok": True, "profile_id": profile_id, "added": added, "updated": updated, "removed": removed, "post_check_changes": post_check_changes}
|
return {"ok": True, "profile_id": profile_id, "added": added, "updated": updated, "removed": removed, "post_check_changes": post_check_changes}
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
with self._lock:
|
with self._lock:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import threading
|
|||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from . import rtorrent, auth, disk_guard
|
from . import rtorrent, auth, disk_guard, operation_logs
|
||||||
from .preferences import get_profile
|
from .preferences import get_profile
|
||||||
from ..config import WORKERS
|
from ..config import WORKERS
|
||||||
from ..db import connect, utcnow, default_user_id
|
from ..db import connect, utcnow, default_user_id
|
||||||
@@ -300,6 +300,7 @@ def _run(job_id: str):
|
|||||||
if not _mark_running(job_id, attempts):
|
if not _mark_running(job_id, attempts):
|
||||||
return
|
return
|
||||||
event_meta = _job_event_meta(payload)
|
event_meta = _job_event_meta(payload)
|
||||||
|
operation_logs.record_job_event(profile["id"], job["action"], "started", payload, job_id=job_id, user_id=int(job.get("user_id") or 0))
|
||||||
_emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, **event_meta})
|
_emit("operation_started", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, **event_meta})
|
||||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
|
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
|
||||||
result = _execute(profile, job["action"], payload)
|
result = _execute(profile, job["action"], payload)
|
||||||
@@ -308,6 +309,7 @@ def _run(job_id: str):
|
|||||||
if fresh and fresh["status"] != "running":
|
if fresh and fresh["status"] != "running":
|
||||||
return
|
return
|
||||||
_set_job(job_id, "done", result=result, finished=True)
|
_set_job(job_id, "done", result=result, finished=True)
|
||||||
|
operation_logs.record_job_event(profile["id"], job["action"], "done", payload, result=result or {}, job_id=job_id, user_id=int(job.get("user_id") or 0))
|
||||||
_emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta})
|
_emit("operation_finished", {"job_id": job_id, "action": job["action"], "profile_id": profile["id"], "hashes": payload.get("hashes") or [], "hash_count": len(payload.get("hashes") or []), "bulk": len(payload.get("hashes") or []) > 1, "result": result, **event_meta})
|
||||||
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
|
_emit("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -319,6 +321,8 @@ def _run(job_id: str):
|
|||||||
return
|
return
|
||||||
status = "pending" if attempts < max_attempts else "failed"
|
status = "pending" if attempts < max_attempts else "failed"
|
||||||
_set_job(job_id, status, str(exc), finished=(status == "failed"))
|
_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))
|
||||||
_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("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})
|
_emit("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": status, "error": str(exc), "attempts": attempts})
|
||||||
if status == "pending":
|
if status == "pending":
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { pollerSource } from './poller.js';
|
|||||||
import { profilesSource } from './profiles.js';
|
import { profilesSource } from './profiles.js';
|
||||||
import { dashboardSource } from './dashboard.js';
|
import { dashboardSource } from './dashboard.js';
|
||||||
import { chartsSource } from './charts.js';
|
import { chartsSource } from './charts.js';
|
||||||
|
import { operationLogsSource } from './operationLogs.js';
|
||||||
import { bootstrapSource } from './bootstrap.js';
|
import { bootstrapSource } from './bootstrap.js';
|
||||||
|
|
||||||
export const moduleSources = [
|
export const moduleSources = [
|
||||||
@@ -33,6 +34,7 @@ export const moduleSources = [
|
|||||||
pollerSource,
|
pollerSource,
|
||||||
profilesSource,
|
profilesSource,
|
||||||
chartsSource,
|
chartsSource,
|
||||||
|
operationLogsSource,
|
||||||
bootstrapSource,
|
bootstrapSource,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
2
pytorrent/static/js/bootstrap.js
vendored
2
pytorrent/static/js/bootstrap.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
pytorrent/static/js/operationLogs.js
Normal file
1
pytorrent/static/js/operationLogs.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -4164,3 +4164,83 @@ body,
|
|||||||
overflow-wrap: anywhere;
|
overflow-wrap: anywhere;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Operation logs */
|
||||||
|
.operation-log-toolbar,
|
||||||
|
.operation-log-settings-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem;
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-type-filter {
|
||||||
|
max-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-search {
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-settings-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(130px, 1fr));
|
||||||
|
gap: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-stat,
|
||||||
|
.operation-log-panels section {
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: .75rem;
|
||||||
|
padding: .75rem;
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-stat span {
|
||||||
|
display: block;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-panels {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: .75rem;
|
||||||
|
margin-top: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: .25rem 0;
|
||||||
|
border-bottom: 1px solid var(--bs-border-color-translucent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-row:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-table td {
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
.operation-log-type-filter,
|
||||||
|
.operation-log-search {
|
||||||
|
max-width: none;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.operation-log-toolbar > .btn,
|
||||||
|
.operation-log-settings-actions > .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user