logs #1
@@ -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"]
|
||||||
|
|||||||
@@ -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"])))
|
||||||
@@ -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,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
Vendored
+1
-1
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
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