logs_commit1

This commit is contained in:
Mateusz Gruszczyński
2026-05-20 08:21:58 +02:00
parent 7401feff63
commit 94f81911a1
13 changed files with 380 additions and 5 deletions

View File

@@ -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 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 (
domain TEXT PRIMARY KEY,
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_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 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 = [
@@ -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_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_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:

View File

@@ -10,5 +10,6 @@ from . import automations as _automations_routes
from . import smart_queue as _smart_queue_routes
from . import system as _system_routes
from . import backup as _backup_routes
from . import operation_logs as _operation_logs_routes
__all__ = ["bp"]

View 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"])))

View 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}

View File

@@ -2,7 +2,7 @@ from __future__ import annotations
from threading import RLock
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"}
@@ -58,6 +58,8 @@ class TorrentCache:
self._data[profile_id] = fresh
self._errors[profile_id] = ""
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}
except Exception as exc:
with self._lock:

View File

@@ -5,7 +5,7 @@ import threading
import time
import uuid
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 ..config import WORKERS
from ..db import connect, utcnow, default_user_id
@@ -300,6 +300,7 @@ def _run(job_id: str):
if not _mark_running(job_id, attempts):
return
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("job_update", {"id": job_id, "profile_id": profile["id"], "status": "running", "attempts": attempts})
result = _execute(profile, job["action"], payload)
@@ -308,6 +309,7 @@ def _run(job_id: str):
if fresh and fresh["status"] != "running":
return
_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("job_update", {"id": job_id, "profile_id": profile["id"], "status": "done", "result": result})
except Exception as exc:
@@ -319,6 +321,8 @@ def _run(job_id: str):
return
status = "pending" if attempts < max_attempts else "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("job_update", {"id": job_id, "profile_id": job.get("profile_id"), "status": status, "error": str(exc), "attempts": attempts})
if status == "pending":

View File

@@ -14,6 +14,7 @@ import { pollerSource } from './poller.js';
import { profilesSource } from './profiles.js';
import { dashboardSource } from './dashboard.js';
import { chartsSource } from './charts.js';
import { operationLogsSource } from './operationLogs.js';
import { bootstrapSource } from './bootstrap.js';
export const moduleSources = [
@@ -33,6 +34,7 @@ export const moduleSources = [
pollerSource,
profilesSource,
chartsSource,
operationLogsSource,
bootstrapSource,
];

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

View File

@@ -4164,3 +4164,83 @@ body,
overflow-wrap: anywhere;
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