first commit
This commit is contained in:
378
pytorrent/routes/system.py
Normal file
378
pytorrent/routes/system.py
Normal file
@@ -0,0 +1,378 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ._shared import *
|
||||
|
||||
@bp.get("/system/disk")
|
||||
def system_disk():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"})
|
||||
try:
|
||||
return ok({"disk": _user_disk_status(profile)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)})
|
||||
|
||||
|
||||
|
||||
@bp.get("/system/status")
|
||||
def system_status():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"})
|
||||
try:
|
||||
status = rtorrent.system_status(profile)
|
||||
status["disk"] = _user_disk_status(profile)
|
||||
if bool(profile.get("is_remote")):
|
||||
try:
|
||||
# Note: Remote profiles must report CPU/RAM from the rTorrent host, not hide the footer stats.
|
||||
usage = rtorrent.remote_system_usage(profile)
|
||||
status.update(usage)
|
||||
status["usage_available"] = True
|
||||
except Exception as exc:
|
||||
status["usage_source"] = "rtorrent-remote"
|
||||
status["usage_available"] = False
|
||||
status["usage_error"] = str(exc)
|
||||
else:
|
||||
status["cpu"] = psutil.cpu_percent(interval=None)
|
||||
status["ram"] = psutil.virtual_memory().percent
|
||||
status["usage_source"] = "local"
|
||||
status["usage_available"] = True
|
||||
# Note: REST status returns the latest records without waiting for the next Socket.IO message.
|
||||
status["speed_peaks"] = speed_peaks.record(profile["id"], status.get("down_rate", 0), status.get("up_rate", 0))
|
||||
return ok({"status": status})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)})
|
||||
|
||||
|
||||
|
||||
@bp.get("/health")
|
||||
def health_check():
|
||||
# Note: Lightweight health endpoint avoids rTorrent calls, making it safe for frequent monitoring.
|
||||
try:
|
||||
with connect() as conn:
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
return ok({"status": "ok"})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "status": "error", "error": str(exc)}), 500
|
||||
|
||||
|
||||
@bp.get("/health/nagios")
|
||||
def health_check_nagios():
|
||||
# Note: Plain-text response is compatible with simple Nagios check_http probes.
|
||||
try:
|
||||
with connect() as conn:
|
||||
conn.execute("SELECT 1").fetchone()
|
||||
return "OK - pyTorrent API healthy\n", 200, {"Content-Type": "text/plain; charset=utf-8"}
|
||||
except Exception as exc:
|
||||
return f"CRITICAL - pyTorrent API unhealthy: {exc}\n", 500, {"Content-Type": "text/plain; charset=utf-8"}
|
||||
|
||||
|
||||
@bp.get("/app/status")
|
||||
def app_status():
|
||||
started = time.perf_counter()
|
||||
profile = preferences.active_profile()
|
||||
proc = psutil.Process(os.getpid())
|
||||
try:
|
||||
jobs = list_jobs(10, 0)
|
||||
jobs_total = jobs.get("total", 0)
|
||||
except Exception:
|
||||
jobs_total = 0
|
||||
status = {
|
||||
"pytorrent": {
|
||||
"ok": True,
|
||||
"pid": os.getpid(),
|
||||
"uptime_seconds": round(time.time() - proc.create_time(), 1),
|
||||
"memory_rss": proc.memory_info().rss,
|
||||
"memory_rss_h": rtorrent.human_size(proc.memory_info().rss),
|
||||
"threads": proc.num_threads(),
|
||||
"cpu_percent": proc.cpu_percent(interval=None),
|
||||
"jobs_total": jobs_total,
|
||||
"python": platform.python_version(),
|
||||
"platform": platform.platform(),
|
||||
"executable": sys.executable,
|
||||
"worker_threads": WORKERS,
|
||||
"open_files": _safe_len(proc.open_files) if hasattr(proc, "open_files") else None,
|
||||
"connections": _safe_len(lambda: proc.net_connections(kind="inet")) if hasattr(proc, "net_connections") else None,
|
||||
},
|
||||
"cleanup": cleanup_summary(),
|
||||
"profile": profile,
|
||||
"scgi": None,
|
||||
}
|
||||
if profile:
|
||||
try:
|
||||
status["scgi"] = rtorrent.scgi_diagnostics(profile)
|
||||
except Exception as exc:
|
||||
status["scgi"] = {"ok": False, "error": str(exc), "url": profile.get("scgi_url")}
|
||||
try:
|
||||
# Note: The diagnostics panel shows the same DL/UL records as the footer.
|
||||
status["speed_peaks"] = speed_peaks.current(profile["id"])
|
||||
except Exception as exc:
|
||||
status["speed_peaks"] = {"error": str(exc)}
|
||||
try:
|
||||
prefs = preferences.get_preferences()
|
||||
status["port_check"] = {"status": "disabled", "enabled": False} if not bool((prefs or {}).get("port_check_enabled")) else port_check_status(force=False)
|
||||
except Exception as exc:
|
||||
status["port_check"] = {"status": "error", "error": str(exc)}
|
||||
status["api_ms"] = round((time.perf_counter() - started) * 1000, 2)
|
||||
return ok({"status": status})
|
||||
|
||||
|
||||
|
||||
@bp.get("/port-check")
|
||||
def port_check_get():
|
||||
prefs = preferences.get_preferences()
|
||||
if not bool((prefs or {}).get("port_check_enabled")):
|
||||
return ok({"port_check": {"status": "disabled", "enabled": False}})
|
||||
return ok({"port_check": port_check_status(force=False)})
|
||||
|
||||
|
||||
|
||||
@bp.post("/port-check")
|
||||
def port_check_post():
|
||||
return ok({"port_check": port_check_status(force=True)})
|
||||
|
||||
|
||||
|
||||
@bp.get("/jobs")
|
||||
def jobs_list():
|
||||
limit = int(request.args.get("limit", 50))
|
||||
offset = int(request.args.get("offset", 0))
|
||||
data = list_jobs(limit, offset)
|
||||
return ok({"jobs": data["rows"], "total": data["total"], "limit": data["limit"], "offset": data["offset"]})
|
||||
|
||||
|
||||
|
||||
@bp.post("/jobs/clear")
|
||||
def jobs_clear():
|
||||
if str(request.args.get("force") or "").lower() in {"1", "true", "yes"}:
|
||||
# Note: Emergency cleanup keeps the endpoint behavior unchanged, while force=1 enables rescue mode.
|
||||
deleted = emergency_clear_jobs()
|
||||
return ok({"deleted": deleted, "emergency": True})
|
||||
deleted = clear_jobs()
|
||||
return ok({"deleted": deleted, "emergency": False})
|
||||
|
||||
|
||||
|
||||
@bp.get("/cleanup/summary")
|
||||
def cleanup_status():
|
||||
return ok({"cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/cleanup/cache")
|
||||
def cleanup_profile_cache():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
profile_id = int(profile["id"])
|
||||
deleted: dict[str, int | dict] = {}
|
||||
# Note: Profile cache cleanup removes derived cache only. Torrents, preferences, rules and history stay intact.
|
||||
deleted["torrent_cache_rows"] = torrent_cache.clear_profile(profile_id)
|
||||
try:
|
||||
from ..services.torrent_summary import invalidate_summary
|
||||
invalidate_summary(profile_id)
|
||||
deleted["torrent_summary"] = 1
|
||||
except Exception:
|
||||
deleted["torrent_summary"] = 0
|
||||
try:
|
||||
runtime = rtorrent.clear_profile_runtime_caches(profile_id)
|
||||
except Exception as exc:
|
||||
runtime = {"error": str(exc)}
|
||||
deleted["runtime"] = runtime
|
||||
with connect() as conn:
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='torrent_stats_cache'").fetchone()
|
||||
deleted["torrent_stats_cache"] = int((conn.execute("DELETE FROM torrent_stats_cache WHERE profile_id=?", (profile_id,)).rowcount if exists else 0) or 0)
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='tracker_summary_cache'").fetchone()
|
||||
deleted["tracker_summary_cache"] = int((conn.execute("DELETE FROM tracker_summary_cache WHERE profile_id=?", (profile_id,)).rowcount if exists else 0) or 0)
|
||||
conn.execute("DELETE FROM app_settings WHERE key LIKE ?", (f"port_check:{profile_id}:%",))
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
@bp.post("/cleanup/jobs")
|
||||
def cleanup_jobs():
|
||||
deleted = clear_jobs()
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/cleanup/smart-queue")
|
||||
def cleanup_smart_queue():
|
||||
with connect() as conn:
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone()
|
||||
if not exists:
|
||||
deleted = 0
|
||||
else:
|
||||
cur = conn.execute("DELETE FROM smart_queue_history")
|
||||
deleted = int(cur.rowcount or 0)
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/cleanup/planner")
|
||||
def cleanup_planner():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
# Note: Planner cleanup removes only the active profile action history, not saved Planner settings.
|
||||
deleted = download_planner.clear_history(int(profile["id"]))
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
@bp.post("/cleanup/automations")
|
||||
def cleanup_automations():
|
||||
with connect() as conn:
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
|
||||
if not exists:
|
||||
deleted = 0
|
||||
else:
|
||||
# Note: Cleanup panel removes only automation logs, not saved automation rules.
|
||||
cur = conn.execute("DELETE FROM automation_history")
|
||||
deleted = int(cur.rowcount or 0)
|
||||
return ok({"deleted": deleted, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/cleanup/all")
|
||||
def cleanup_all():
|
||||
deleted_jobs = clear_jobs()
|
||||
active_profile = preferences.active_profile()
|
||||
deleted_planner = download_planner.clear_history(int(active_profile["id"])) if active_profile else 0
|
||||
with connect() as conn:
|
||||
exists = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='smart_queue_history'").fetchone()
|
||||
if not exists:
|
||||
deleted_smart = 0
|
||||
else:
|
||||
cur = conn.execute("DELETE FROM smart_queue_history")
|
||||
deleted_smart = int(cur.rowcount or 0)
|
||||
exists_auto = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='automation_history'").fetchone()
|
||||
if not exists_auto:
|
||||
deleted_auto = 0
|
||||
else:
|
||||
cur = conn.execute("DELETE FROM automation_history")
|
||||
deleted_auto = int(cur.rowcount or 0)
|
||||
return ok({"deleted": {"jobs": deleted_jobs, "smart_queue_history": deleted_smart, "planner_history": deleted_planner, "automation_history": deleted_auto}, "cleanup": cleanup_summary()})
|
||||
|
||||
|
||||
|
||||
@bp.post("/jobs/<job_id>/cancel")
|
||||
def jobs_cancel(job_id: str):
|
||||
require_profile_write(_job_profile_id(job_id))
|
||||
if not cancel_job(job_id):
|
||||
return jsonify({"ok": False, "error": "Only unfinished jobs can be cancelled"}), 400
|
||||
return ok({"emergency": True})
|
||||
|
||||
|
||||
|
||||
@bp.post("/jobs/<job_id>/force")
|
||||
def jobs_force(job_id: str):
|
||||
require_profile_write(_job_profile_id(job_id))
|
||||
if not force_job(job_id):
|
||||
return jsonify({"ok": False, "error": "Only pending jobs can be forced"}), 400
|
||||
return ok({"job_id": job_id})
|
||||
|
||||
|
||||
@bp.post("/jobs/<job_id>/retry")
|
||||
def jobs_retry(job_id: str):
|
||||
require_profile_write(_job_profile_id(job_id))
|
||||
if not retry_job(job_id):
|
||||
return jsonify({"ok": False, "error": "Only failed or cancelled jobs can be retried"}), 400
|
||||
return ok()
|
||||
|
||||
|
||||
|
||||
@bp.get("/path/default")
|
||||
def path_default():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
try:
|
||||
return ok({"path": rtorrent.default_download_path(profile)})
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.get("/path/browse")
|
||||
def path_browse():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({"ok": False, "error": "No profile"}), 400
|
||||
base = request.args.get("path") or ""
|
||||
try:
|
||||
return ok(rtorrent.browse_path(profile, base))
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "error": str(exc)}), 400
|
||||
|
||||
|
||||
|
||||
@bp.get('/rtorrent-config')
|
||||
def rtorrent_config_get():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
return ok({'config': rtorrent.get_config(profile)})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
|
||||
|
||||
@bp.post('/rtorrent-config')
|
||||
def rtorrent_config_save():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
result = rtorrent.set_config(profile, data.get('values') or {}, bool(data.get('apply_now', True)), bool(data.get('apply_on_start')), data.get('clear_keys') or [])
|
||||
if not result.get('ok'):
|
||||
return jsonify({'ok': False, 'error': 'Some settings were not saved', 'result': result}), 400
|
||||
return ok({'result': result})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
|
||||
|
||||
|
||||
|
||||
@bp.post('/rtorrent-config/reset')
|
||||
def rtorrent_config_reset():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
# Note: This clears only pyTorrent-saved interface overrides and then reloads live rTorrent values.
|
||||
return ok({'config': rtorrent.reset_config_overrides(profile)})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 400
|
||||
|
||||
@bp.post('/rtorrent-config/generate')
|
||||
def rtorrent_config_generate():
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return jsonify({'ok': False, 'error': 'No profile'}), 400
|
||||
try:
|
||||
data = request.get_json(silent=True) or {}
|
||||
return ok({'config_text': rtorrent.generate_config_text(data.get('values') or {})})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc)}), 500
|
||||
|
||||
|
||||
@bp.get('/traffic/history')
|
||||
def traffic_history_get():
|
||||
from ..services import traffic_history
|
||||
profile = preferences.active_profile()
|
||||
if not profile:
|
||||
return ok({'history': {'range': request.args.get('range') or '7d', 'bucket': 'day', 'rows': []}})
|
||||
range_name = request.args.get('range') or '7d'
|
||||
if range_name not in {'15m', '1h', '3h', '6h', '24h', '7d', '30d', '90d'}:
|
||||
range_name = '7d'
|
||||
try:
|
||||
try:
|
||||
from ..services import rtorrent
|
||||
status = rtorrent.system_status(profile)
|
||||
traffic_history.record(profile['id'], status.get('down_rate', 0), status.get('up_rate', 0), status.get('total_down', 0), status.get('total_up', 0), force=True)
|
||||
except Exception:
|
||||
pass
|
||||
return ok({'history': traffic_history.history(profile['id'], range_name)})
|
||||
except Exception as exc:
|
||||
return jsonify({'ok': False, 'error': str(exc), 'history': {'range': range_name, 'rows': []}})
|
||||
|
||||
Reference in New Issue
Block a user