This commit is contained in:
Mateusz Gruszczyński
2026-05-08 11:52:01 +02:00
parent f445d25c5d
commit cd6c4fad85
9 changed files with 293 additions and 16 deletions

View File

@@ -122,6 +122,8 @@ def create_app() -> Flask:
app.register_blueprint(api_bp) app.register_blueprint(api_bp)
register_error_pages(app) register_error_pages(app)
init_db() init_db()
from .services.speed_peaks import load_cache
load_cache()
from .services.auth import install_guards from .services.auth import install_guards
install_guards(app) install_guards(app)

View File

@@ -39,6 +39,7 @@ CREATE TABLE IF NOT EXISTS user_preferences (
peers_refresh_seconds INTEGER DEFAULT 0, peers_refresh_seconds INTEGER DEFAULT 0,
port_check_enabled INTEGER DEFAULT 0, port_check_enabled INTEGER DEFAULT 0,
footer_items_json TEXT, footer_items_json TEXT,
title_speed_enabled INTEGER DEFAULT 0,
created_at TEXT NOT NULL, created_at TEXT NOT NULL,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id) FOREIGN KEY(user_id) REFERENCES users(id)
@@ -197,6 +198,22 @@ CREATE TABLE IF NOT EXISTS traffic_history (
CREATE INDEX IF NOT EXISTS idx_traffic_history_profile_created ON traffic_history(profile_id, created_at); CREATE INDEX IF NOT EXISTS idx_traffic_history_profile_created ON traffic_history(profile_id, created_at);
CREATE TABLE IF NOT EXISTS transfer_speed_peaks (
profile_id INTEGER PRIMARY KEY,
session_started_at TEXT NOT NULL,
session_down_peak INTEGER DEFAULT 0,
session_up_peak INTEGER DEFAULT 0,
session_down_peak_at TEXT,
session_up_peak_at TEXT,
all_time_down_peak INTEGER DEFAULT 0,
all_time_up_peak INTEGER DEFAULT 0,
all_time_down_peak_at TEXT,
all_time_up_peak_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
FOREIGN KEY(profile_id) REFERENCES rtorrent_profiles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS automation_rules ( CREATE TABLE IF NOT EXISTS automation_rules (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -269,6 +286,7 @@ MIGRATIONS = [
"ALTER TABLE user_preferences ADD COLUMN bootstrap_theme TEXT DEFAULT 'default'", "ALTER TABLE user_preferences ADD COLUMN bootstrap_theme TEXT DEFAULT 'default'",
"ALTER TABLE user_preferences ADD COLUMN font_family TEXT DEFAULT 'default'", "ALTER TABLE user_preferences ADD COLUMN font_family TEXT DEFAULT 'default'",
"ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT", "ALTER TABLE user_preferences ADD COLUMN footer_items_json TEXT",
"ALTER TABLE user_preferences ADD COLUMN title_speed_enabled INTEGER DEFAULT 0",
"ALTER TABLE rtorrent_profiles ADD COLUMN max_parallel_jobs INTEGER DEFAULT 5", "ALTER TABLE rtorrent_profiles ADD COLUMN max_parallel_jobs INTEGER DEFAULT 5",
"ALTER TABLE rtorrent_profiles ADD COLUMN is_remote INTEGER DEFAULT 0", "ALTER TABLE rtorrent_profiles ADD COLUMN is_remote INTEGER DEFAULT 0",
"ALTER TABLE jobs ADD COLUMN attempts INTEGER DEFAULT 0", "ALTER TABLE jobs ADD COLUMN attempts INTEGER DEFAULT 0",

View File

@@ -17,7 +17,7 @@ from flask import Blueprint, jsonify, request, abort
from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS from ..config import DB_PATH, JOBS_RETENTION_DAYS, SMART_QUEUE_HISTORY_RETENTION_DAYS, WORKERS
from ..db import connect, utcnow from ..db import connect, utcnow
from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write from ..services.auth import current_user_id as default_user_id, current_user, list_users, save_user, delete_user, login_user, logout_user, enabled as auth_enabled, require_profile_write
from ..services import preferences, rtorrent, torrent_stats from ..services import preferences, rtorrent, torrent_stats, speed_peaks
from ..services.torrent_cache import torrent_cache from ..services.torrent_cache import torrent_cache
from ..services.torrent_summary import cached_summary from ..services.torrent_summary import cached_summary
from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, clear_jobs, emergency_clear_jobs from ..services.workers import enqueue, list_jobs, cancel_job, retry_job, clear_jobs, emergency_clear_jobs
@@ -629,6 +629,8 @@ def system_status():
status["ram"] = psutil.virtual_memory().percent status["ram"] = psutil.virtual_memory().percent
status["usage_source"] = "local" status["usage_source"] = "local"
status["usage_available"] = True status["usage_available"] = True
# Notatka: REST status zwraca ostatnie rekordy bez czekania na kolejny komunikat Socket.IO.
status["speed_peaks"] = speed_peaks.record(profile["id"], status.get("down_rate", 0), status.get("up_rate", 0))
return ok({"status": status}) return ok({"status": status})
except Exception as exc: except Exception as exc:
return jsonify({"ok": False, "error": str(exc)}) return jsonify({"ok": False, "error": str(exc)})
@@ -670,6 +672,11 @@ def app_status():
status["scgi"] = rtorrent.scgi_diagnostics(profile) status["scgi"] = rtorrent.scgi_diagnostics(profile)
except Exception as exc: except Exception as exc:
status["scgi"] = {"ok": False, "error": str(exc), "url": profile.get("scgi_url")} status["scgi"] = {"ok": False, "error": str(exc), "url": profile.get("scgi_url")}
try:
# Notatka: panel diagnostyczny pokazuje te same rekordy DL/UL co stopka.
status["speed_peaks"] = speed_peaks.current(profile["id"])
except Exception as exc:
status["speed_peaks"] = {"error": str(exc)}
try: try:
prefs = preferences.get_preferences() 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) status["port_check"] = {"status": "disabled", "enabled": False} if not bool((prefs or {}).get("port_check_enabled")) else port_check_status(force=False)

View File

@@ -167,6 +167,7 @@ def save_preferences(data: dict, user_id: int | None = None):
peers_refresh_seconds = data.get("peers_refresh_seconds") peers_refresh_seconds = data.get("peers_refresh_seconds")
port_check_enabled = data.get("port_check_enabled") port_check_enabled = data.get("port_check_enabled")
footer_items_json = data.get("footer_items_json") footer_items_json = data.get("footer_items_json")
title_speed_enabled = data.get("title_speed_enabled")
with connect() as conn: with connect() as conn:
now = utcnow() now = utcnow()
if allowed_theme: if allowed_theme:
@@ -183,6 +184,9 @@ def save_preferences(data: dict, user_id: int | None = None):
conn.execute("UPDATE user_preferences SET peers_refresh_seconds=?, updated_at=? WHERE user_id=?", (sec, now, user_id)) conn.execute("UPDATE user_preferences SET peers_refresh_seconds=?, updated_at=? WHERE user_id=?", (sec, now, user_id))
if port_check_enabled is not None: if port_check_enabled is not None:
conn.execute("UPDATE user_preferences SET port_check_enabled=?, updated_at=? WHERE user_id=?", (1 if port_check_enabled else 0, now, user_id)) conn.execute("UPDATE user_preferences SET port_check_enabled=?, updated_at=? WHERE user_id=?", (1 if port_check_enabled else 0, now, user_id))
if title_speed_enabled is not None:
# Notatka: preferencja steruje wyświetlaniem bieżącego DL/UL w tytule karty przeglądarki.
conn.execute("UPDATE user_preferences SET title_speed_enabled=?, updated_at=? WHERE user_id=?", (1 if title_speed_enabled else 0, now, user_id))
if footer_items_json is not None: if footer_items_json is not None:
# Note: Store only JSON objects so footer visibility can be extended without schema churn. # Note: Store only JSON objects so footer visibility can be extended without schema churn.
value = footer_items_json if isinstance(footer_items_json, str) else json.dumps(footer_items_json) value = footer_items_json if isinstance(footer_items_json, str) else json.dumps(footer_items_json)

View File

@@ -0,0 +1,159 @@
from __future__ import annotations
import threading
from typing import Any
from ..db import connect, utcnow
from .rtorrent import human_rate
_SESSION_STARTED_AT = utcnow()
_CACHE: dict[int, dict[str, Any]] = {}
_LOADED = False
_LOCK = threading.Lock()
def _empty_peak(profile_id: int, all_time: dict[str, Any] | None = None) -> dict[str, Any]:
# Notatka: jedna struktura w pamięci trzyma bieżącą sesję i rekord ogólny dla profilu rTorrent.
all_time = all_time or {}
return {
"profile_id": int(profile_id),
"session_started_at": _SESSION_STARTED_AT,
"session_down_peak": 0,
"session_up_peak": 0,
"session_down_peak_at": None,
"session_up_peak_at": None,
"all_time_down_peak": int(all_time.get("all_time_down_peak") or 0),
"all_time_up_peak": int(all_time.get("all_time_up_peak") or 0),
"all_time_down_peak_at": all_time.get("all_time_down_peak_at"),
"all_time_up_peak_at": all_time.get("all_time_up_peak_at"),
}
def load_cache() -> None:
# Notatka: rekordy ogólne są ładowane przy starcie aplikacji, a rekord sesji zaczyna się od zera.
global _LOADED
with _LOCK:
if _LOADED:
return
with connect() as conn:
rows = conn.execute("SELECT * FROM transfer_speed_peaks").fetchall()
for row in rows:
profile_id = int(row.get("profile_id") or 0)
if profile_id:
_CACHE[profile_id] = _empty_peak(profile_id, row)
_LOADED = True
def _ensure_profile(profile_id: int) -> dict[str, Any]:
# Notatka: leniwe ładowanie chroni nowe profile dodane po starcie przed pustymi rekordami.
profile_id = int(profile_id)
item = _CACHE.get(profile_id)
if item:
return item
with connect() as conn:
row = conn.execute("SELECT * FROM transfer_speed_peaks WHERE profile_id=?", (profile_id,)).fetchone()
item = _empty_peak(profile_id, row)
_CACHE[profile_id] = item
return item
def _persist(item: dict[str, Any]) -> None:
# Notatka: SQLite dostaje zapis tylko wtedy, gdy pojawił się nowy rekord sesji lub rekord ogólny.
now = utcnow()
with connect() as conn:
conn.execute(
"""
INSERT INTO transfer_speed_peaks(
profile_id, session_started_at, session_down_peak, session_up_peak,
session_down_peak_at, session_up_peak_at, all_time_down_peak,
all_time_up_peak, all_time_down_peak_at, all_time_up_peak_at,
created_at, updated_at
) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(profile_id) DO UPDATE SET
session_started_at=excluded.session_started_at,
session_down_peak=excluded.session_down_peak,
session_up_peak=excluded.session_up_peak,
session_down_peak_at=excluded.session_down_peak_at,
session_up_peak_at=excluded.session_up_peak_at,
all_time_down_peak=excluded.all_time_down_peak,
all_time_up_peak=excluded.all_time_up_peak,
all_time_down_peak_at=excluded.all_time_down_peak_at,
all_time_up_peak_at=excluded.all_time_up_peak_at,
updated_at=excluded.updated_at
""",
(
int(item["profile_id"]),
item["session_started_at"],
int(item["session_down_peak"]),
int(item["session_up_peak"]),
item.get("session_down_peak_at"),
item.get("session_up_peak_at"),
int(item["all_time_down_peak"]),
int(item["all_time_up_peak"]),
item.get("all_time_down_peak_at"),
item.get("all_time_up_peak_at"),
now,
now,
),
)
def _public(item: dict[str, Any]) -> dict[str, Any]:
# Notatka: frontend dostaje zarówno bajty/s, jak i gotowe etykiety w stylu istniejących prędkości.
return {
"session_started_at": item["session_started_at"],
"session": {
"down": int(item["session_down_peak"]),
"up": int(item["session_up_peak"]),
"down_h": human_rate(int(item["session_down_peak"])),
"up_h": human_rate(int(item["session_up_peak"])),
"down_at": item.get("session_down_peak_at"),
"up_at": item.get("session_up_peak_at"),
},
"all_time": {
"down": int(item["all_time_down_peak"]),
"up": int(item["all_time_up_peak"]),
"down_h": human_rate(int(item["all_time_down_peak"])),
"up_h": human_rate(int(item["all_time_up_peak"])),
"down_at": item.get("all_time_down_peak_at"),
"up_at": item.get("all_time_up_peak_at"),
},
}
def record(profile_id: int, down_rate: int = 0, up_rate: int = 0) -> dict[str, Any]:
# Notatka: poller wywołuje tę funkcję w tle; baza jest aktualizowana tylko po przebiciu rekordu.
load_cache()
down_rate = max(0, int(down_rate or 0))
up_rate = max(0, int(up_rate or 0))
measured_at = utcnow()
changed = False
with _LOCK:
item = _ensure_profile(int(profile_id))
if down_rate > int(item["session_down_peak"]):
item["session_down_peak"] = down_rate
item["session_down_peak_at"] = measured_at
changed = True
if up_rate > int(item["session_up_peak"]):
item["session_up_peak"] = up_rate
item["session_up_peak_at"] = measured_at
changed = True
if down_rate > int(item["all_time_down_peak"]):
item["all_time_down_peak"] = down_rate
item["all_time_down_peak_at"] = measured_at
changed = True
if up_rate > int(item["all_time_up_peak"]):
item["all_time_up_peak"] = up_rate
item["all_time_up_peak_at"] = measured_at
changed = True
result = _public(item)
if changed:
_persist(item)
return result
def current(profile_id: int) -> dict[str, Any]:
# Notatka: REST API może pokazać ostatni znany rekord bez wymuszania nowego pomiaru.
load_cache()
with _LOCK:
return _public(_ensure_profile(int(profile_id)))

View File

@@ -7,7 +7,7 @@ from ..config import POLL_INTERVAL
from .preferences import active_profile, get_profile from .preferences import active_profile, get_profile
from .torrent_cache import torrent_cache from .torrent_cache import torrent_cache
from .torrent_summary import cached_summary from .torrent_summary import cached_summary
from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth from . import rtorrent, smart_queue, traffic_history, automation_rules, torrent_stats, auth, speed_peaks
def _profile_room(profile_id: int) -> str: def _profile_room(profile_id: int) -> str:
@@ -59,6 +59,8 @@ def register_socketio_handlers(socketio):
status["usage_available"] = True status["usage_available"] = True
status["profile_id"] = pid status["profile_id"] = pid
traffic_history.record(pid, status.get("down_rate", 0), status.get("up_rate", 0), status.get("total_down", 0), status.get("total_up", 0)) traffic_history.record(pid, status.get("down_rate", 0), status.get("up_rate", 0), status.get("total_down", 0), status.get("total_up", 0))
# Notatka: najwyższe DL/UL są liczone w tle razem z istniejącym pollerem i zapisywane tylko po przebiciu rekordu.
status["speed_peaks"] = speed_peaks.record(pid, status.get("down_rate", 0), status.get("up_rate", 0))
_emit_profile(socketio, "system_stats", status, pid) _emit_profile(socketio, "system_stats", status, pid)
heartbeat["ok"] = True heartbeat["ok"] = True
except Exception as exc: except Exception as exc:

View File

@@ -17,10 +17,13 @@
let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0); let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0);
let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || "default"; let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || "default";
let fontFamily = window.PYTORRENT?.fontFamily || "default"; let fontFamily = window.PYTORRENT?.fontFamily || "default";
let titleSpeedEnabled = !!Number(window.PYTORRENT?.titleSpeedEnabled || 0);
const BASE_TITLE = document.title || "pyTorrent";
const lastBrowserSpeed = {down: "0 B/s", up: "0 B/s"};
const FOOTER_ITEM_DEFS = [ const FOOTER_ITEM_DEFS = [
["cpu", "CPU"], ["ram", "RAM"], ["usage_chart", "CPU/RAM chart"], ["disk", "Disk"], ["cpu", "CPU"], ["ram", "RAM"], ["usage_chart", "CPU/RAM chart"], ["disk", "Disk"],
["version", "rTorrent version"], ["speed_down", "Download speed"], ["speed_up", "Upload speed"], ["version", "rTorrent version"], ["speed_down", "Download speed"], ["speed_up", "Upload speed"],
["limits", "Speed limits"], ["totals", "Total transfer"], ["port_check", "Port check"], ["speed_peaks", "Peak speeds"], ["limits", "Speed limits"], ["totals", "Total transfer"], ["port_check", "Port check"],
["clock", "Clock"], ["sockets", "Open sockets"], ["shown", "Shown torrents"], ["selected", "Selected torrents"], ["docs", "API docs"] ["clock", "Clock"], ["sockets", "Open sockets"], ["shown", "Shown torrents"], ["selected", "Selected torrents"], ["docs", "API docs"]
]; ];
let footerItems = {...Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, true])), ...(window.PYTORRENT?.footerItems || {})}; let footerItems = {...Object.fromEntries(FOOTER_ITEM_DEFS.map(([key]) => [key, true])), ...(window.PYTORRENT?.footerItems || {})};
@@ -582,6 +585,7 @@
function applyBootstrapTheme(theme){ bootstrapTheme = theme || "default"; const link=$("bootstrapThemeStylesheet"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($("bootstrapThemeSelect")) $("bootstrapThemeSelect").value = bootstrapTheme; } function applyBootstrapTheme(theme){ bootstrapTheme = theme || "default"; const link=$("bootstrapThemeStylesheet"); if(link) link.href = bootstrapThemeUrl(bootstrapTheme); if($("bootstrapThemeSelect")) $("bootstrapThemeSelect").value = bootstrapTheme; }
function applyFontFamily(font){ fontFamily = font || "default"; document.documentElement.dataset.appFont = fontFamily; if($("fontFamilySelect")) $("fontFamilySelect").value = fontFamily; } function applyFontFamily(font){ fontFamily = font || "default"; document.documentElement.dataset.appFont = fontFamily; if($("fontFamilySelect")) $("fontFamilySelect").value = fontFamily; }
async function saveAppearancePreferences(){ applyBootstrapTheme($("bootstrapThemeSelect")?.value || "default"); applyFontFamily($("fontFamilySelect")?.value || "default"); try{ await post("/api/preferences",{bootstrap_theme:bootstrapTheme,font_family:fontFamily}); toast("Appearance preferences saved","success"); }catch(e){ toast(e.message,"danger"); } } async function saveAppearancePreferences(){ applyBootstrapTheme($("bootstrapThemeSelect")?.value || "default"); applyFontFamily($("fontFamilySelect")?.value || "default"); try{ await post("/api/preferences",{bootstrap_theme:bootstrapTheme,font_family:fontFamily}); toast("Appearance preferences saved","success"); }catch(e){ toast(e.message,"danger"); } }
if($("titleSpeedEnabled")) $("titleSpeedEnabled").checked=titleSpeedEnabled;
function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers'); }, peersRefreshSeconds*1000); } } function setupPeersRefresh(tab=activeTab()){ clearInterval(peersRefreshTimer); peersRefreshTimer=null; if($('peersRefreshSelect')) $('peersRefreshSelect').value=String(peersRefreshSeconds||0); if(tab==='peers' && peersRefreshSeconds>0){ peersRefreshTimer=setInterval(()=>{ if(activeTab()==='peers' && selectedHash) loadDetails('peers'); }, peersRefreshSeconds*1000); } }
function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia("(max-width: 900px)").matches; document.body.classList.toggle("mobile-mode", auto || document.body.classList.contains("mobile-mode-manual")); scheduleRender(true); } function syncMobileMode(){ const auto=window.matchMedia&&window.matchMedia("(max-width: 900px)").matches; document.body.classList.toggle("mobile-mode", auto || document.body.classList.contains("mobile-mode-manual")); scheduleRender(true); }
@@ -826,6 +830,46 @@
try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); } try{ await post('/api/preferences',{footer_items_json:footerItems}); toast('Footer preferences saved','success'); }
catch(e){ toast(e.message,'danger'); } catch(e){ toast(e.message,'danger'); }
} }
function compactSpeedText(value){
// Notatka: stopka ma ograniczone miejsce, więc usuwa spację tylko z etykiet prędkości.
return String(value || '0 B/s').replace(/\s+(?=[KMGT]?i?B\/s$|B\/s$)/, '');
}
function speedPairText(down, up){
// Notatka: spójny zapis pary DL/UL jest używany w stopce i diagnostyce.
return `${compactSpeedText(down)} / ${compactSpeedText(up)}`;
}
function peakDateText(value){
// Notatka: skraca ISO timestamp z bazy do czytelnej etykiety w podpowiedzi.
return value ? String(value).replace('T',' ').replace(/\+00:00$/, ' UTC') : '-';
}
function updateSpeedPeaks(peaks={}){
// Notatka: prezentuje rekord sesji i rekord ogólny obok bieżących prędkości w stopce.
const session=peaks.session||{};
const allTime=peaks.all_time||{};
const sessionText=speedPairText(session.down_h, session.up_h);
const allTimeText=speedPairText(allTime.down_h, allTime.up_h);
if($('statPeakSession')) $('statPeakSession').textContent=sessionText;
if($('statPeakAllTime')) $('statPeakAllTime').textContent=allTimeText;
const box=$('statusSpeedPeaks');
if(box){
box.title=`Peak speed DL/UL\nSession: ${sessionText}\nSession DL at: ${peakDateText(session.down_at)}\nSession UL at: ${peakDateText(session.up_at)}\nAll-time: ${allTimeText}\nAll-time DL at: ${peakDateText(allTime.down_at)}\nAll-time UL at: ${peakDateText(allTime.up_at)}`;
}
}
function updateBrowserSpeedTitle(downH, upH){
// Notatka: w stylu ruTorrent pokazuje DL/UL w tytule karty; window.status jest próbą dla starszych przeglądarek.
if(downH != null) lastBrowserSpeed.down=downH || '0 B/s';
if(upH != null) lastBrowserSpeed.up=upH || '0 B/s';
const speedTitle=`DL ${lastBrowserSpeed.down} / UL ${lastBrowserSpeed.up}`;
document.title=titleSpeedEnabled ? `${speedTitle} - ${BASE_TITLE}` : BASE_TITLE;
try{ window.status=titleSpeedEnabled ? speedTitle : ''; }catch(e){}
}
async function saveTitleSpeedPreference(){
// Notatka: zmiana działa od razu i jest zapisywana jako preferencja użytkownika.
titleSpeedEnabled=!!$('titleSpeedEnabled')?.checked;
updateBrowserSpeedTitle();
try{ await post('/api/preferences',{title_speed_enabled:titleSpeedEnabled}); toast('Browser title speed saved','success'); }
catch(e){ toast(e.message,'danger'); }
}
function updateFooterClock(){ function updateFooterClock(){
const el=$('statClock'); const el=$('statClock');
if(el) el.textContent=new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'}); if(el) el.textContent=new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit', second:'2-digit'});
@@ -868,11 +912,13 @@
const j=await (await fetch('/api/app/status')).json(); const j=await (await fetch('/api/app/status')).json();
if(!j.ok) throw new Error(j.error||'Failed to load diagnostics'); if(!j.ok) throw new Error(j.error||'Failed to load diagnostics');
const st=j.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}; const st=j.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{}, pc=st.port_check||{}, cleanup=st.cleanup||{}, db=cleanup.database||{};
const peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{};
const cards=[ const cards=[
diagCard('pyTorrent PID', py.pid), diagCard('pyTorrent uptime', `${py.uptime_seconds||0}s`), diagCard('Memory RSS', py.memory_rss_h||py.memory_rss), diagCard('pyTorrent PID', py.pid), diagCard('pyTorrent uptime', `${py.uptime_seconds||0}s`), diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),
diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Jobs total', py.jobs_total), diagCard('Threads', py.threads), diagCard('CPU', `${py.cpu_percent ?? '-'}%`), diagCard('Jobs total', py.jobs_total),
diagCard('Worker threads', py.worker_threads), diagCard('Python', py.python||'-'), diagCard('DB size', db.size_h||'-'), diagCard('Worker threads', py.worker_threads), diagCard('Python', py.python||'-'), diagCard('DB size', db.size_h||'-'),
diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`),
diagCard('Peak session DL/UL', speedPairText(peakSession.down_h, peakSession.up_h)), diagCard('Peak all-time DL/UL', speedPairText(peakAllTime.down_h, peakAllTime.up_h)),
diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-'), diagCard('Job logs clearable', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-'),
diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')), diagCard('Port check', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':''), diagCard('Incoming port', pc.port||'-'), diagCard('Port check source', pc.source||(pc.enabled?'unknown':'disabled')),
diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),
@@ -927,7 +973,7 @@
function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); } function awaitMaybeRun(action){ runAction(action).catch?.(()=>{}); }
document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); document.querySelectorAll('.filter').forEach(x=>{ if(x.dataset.filter===mobileFilter.dataset.filter) x.classList.add('active'); }); activeFilter=mobileFilter.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ const all=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); if(all) visibleRows.forEach(t=>selected.delete(t.hash)); else visibleRows.forEach(t=>selected.add(t.hash)); if(selected.size===0){selectedHash=null;lastSelectedHash=null;} else {selectedHash=[...selected][selected.size-1];lastSelectedHash=selectedHash;} scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=h; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); }); document.addEventListener('click',e=>{ const ctx=$('ctxMenu'); if(!e.target.closest('#ctxMenu')) ctx.style.display='none'; const mobileFilter=e.target.closest('#mobileFilterBar .mobile-filter'); if(mobileFilter){ document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); document.querySelectorAll('.filter').forEach(x=>{ if(x.dataset.filter===mobileFilter.dataset.filter) x.classList.add('active'); }); activeFilter=mobileFilter.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; if($('mobileList'))$('mobileList').scrollTop=0; scheduleRender(true); return; } const mobileSelectAll=e.target.closest('#mobileSelectAll'); if(mobileSelectAll){ const all=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); if(all) visibleRows.forEach(t=>selected.delete(t.hash)); else visibleRows.forEach(t=>selected.add(t.hash)); if(selected.size===0){selectedHash=null;lastSelectedHash=null;} else {selectedHash=[...selected][selected.size-1];lastSelectedHash=selectedHash;} scheduleRender(true); return; } const mobileClear=e.target.closest('#mobileClearSelection'); if(mobileClear){ selected.clear(); selectedHash=null; lastSelectedHash=null; scheduleRender(true); return; } const mobileAct=e.target.closest('.mobile-card [data-action]'); if(mobileAct){ const card0=mobileAct.closest('.mobile-card'); selected.clear(); selected.add(card0.dataset.hash); selectedHash=card0.dataset.hash; awaitMaybeRun(mobileAct.dataset.action); scheduleRender(true); return; } const card=e.target.closest('.mobile-card'); const tr=e.target.closest('tr[data-hash]'); const row=tr||card; if(row){ const h=row.dataset.hash; const additive=e.ctrlKey||e.metaKey; if(e.shiftKey){ setSelectionRange(h, additive); } else if(e.target.classList.contains('row-check')){ e.target.checked?selected.add(h):selected.delete(h); lastSelectedHash=h; selectedHash=h; } else { selectedHash=h; if(!additive)selected.clear(); selected.add(h); lastSelectedHash=h; loadDetails(activeTab()); } scheduleRender(true); } const copy=e.target.closest('[data-copy]'); if(copy) copySelected(copy.dataset.copy); const smartEx=e.target.closest('#smartExcludeCtx'); if(smartEx){ selectedHashes().forEach(h=>post('/api/smart-queue/exclusion',{hash:h,excluded:true,reason:'manual'}).catch(()=>{})); toast('Smart Queue exception saved','success'); loadSmartQueue().catch(()=>{}); } const act=e.target.closest('.torrent-action,[data-action]'); if(act&&act.dataset.action&&!act.closest('#detailTabs')&&!act.closest('.mobile-card')) runAction(act.dataset.action); });
document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; }); document.addEventListener('contextmenu',e=>{ const tr=e.target.closest('tr[data-hash],.mobile-card'); if(!tr)return; e.preventDefault(); selectedHash=tr.dataset.hash; if(!selected.has(selectedHash)){selected.clear();selected.add(selectedHash);scheduleRender(true);} const m=$('ctxMenu'); m.style.left=`${e.pageX}px`; m.style.top=`${e.pageY}px`; m.style.display='block'; });
document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeFilter=b.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences); document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>th.addEventListener('click',()=>{ const key=th.dataset.sort; if(sortState.key===key) sortState.dir*=-1; else sortState={key,dir:1}; scheduleRender(true); })); $('tableWrap')?.addEventListener('scroll',()=>scheduleRender(false),{passive:true}); $('selectAll')?.addEventListener('change',e=>{selected.clear(); if(e.target.checked)visibleRows.forEach(t=>selected.add(t.hash)); scheduleRender(true);}); $('searchBox')?.addEventListener('input',()=>{if($('tableWrap'))$('tableWrap').scrollTop=0;scheduleRender(true);}); document.querySelectorAll('.filter').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('.filter').forEach(x=>x.classList.remove('active')); b.classList.add('active'); activeFilter=b.dataset.filter; if($('tableWrap'))$('tableWrap').scrollTop=0; scheduleRender(true);})); document.querySelectorAll('#detailTabs .nav-link').forEach(b=>b.addEventListener('click',()=>{document.querySelectorAll('#detailTabs .nav-link').forEach(x=>x.classList.remove('active')); b.classList.add('active'); loadDetails(b.dataset.tab);})); document.addEventListener('change',e=>{ const sel=e.target.closest('.file-priority'); if(sel){ setFilePriorities([{index:Number(sel.dataset.index),priority:Number(sel.value)}]); return; } if(e.target && e.target.id==='fileSelectAll'){ document.querySelectorAll('#detailPane .file-check').forEach(cb=>cb.checked=e.target.checked); } }); document.addEventListener('click',e=>{ const bulk=e.target.closest('.file-priority-bulk'); if(!bulk) return; const priority=Number(bulk.dataset.priority); const checked=[...document.querySelectorAll('#detailPane .file-check:checked')].map(cb=>({index:Number(cb.dataset.index),priority})); if(!checked.length) return toast('No files selected','warning'); setFilePriorities(checked); }); document.addEventListener('click',e=>{ const add=e.target.closest('#trackerAddBtn'); if(add){ const url=$('trackerAddUrl')?.value||''; trackerAction('add',{url}); return; } const del=e.target.closest('.tracker-delete'); if(del && !del.disabled){ trackerAction('delete',{index:Number(del.dataset.index)}); return; } const rea=e.target.closest('#trackerReannounceBtn'); if(rea) trackerAction('reannounce',{}); }); $('appStatusRefreshBtn')?.addEventListener('click',loadAppStatus); $('portCheckEnabled')?.addEventListener('change',savePortCheckPref); $('portCheckNowBtn')?.addEventListener('click',()=>loadPortCheck(true)); $('bootstrapThemeSelect')?.addEventListener('change',saveAppearancePreferences); $('fontFamilySelect')?.addEventListener('change',saveAppearancePreferences); $('titleSpeedEnabled')?.addEventListener('change',saveTitleSpeedPreference); $('saveFooterPrefsBtn')?.addEventListener('click',saveFooterPreferences);
document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s')runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); }); document.addEventListener('keydown',e=>{ const tag=(e.target?.tagName||'').toLowerCase(); const editable=tag==='input'||tag==='textarea'||tag==='select'||e.target?.isContentEditable; if(editable){ if(e.key==='Enter' && e.target?.id==='labelInput'){ e.preventDefault(); $('addLabelToSelectionBtn')?.click(); } return; } if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='a'){e.preventDefault();selected.clear();visibleRows.forEach(t=>selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='i'){e.preventDefault();visibleRows.forEach(t=>selected.has(t.hash)?selected.delete(t.hash):selected.add(t.hash));scheduleRender(true);} if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==='o'){e.preventDefault();new bootstrap.Modal($('addModal')).show();} if(e.key==='Escape'){selected.clear();scheduleRender(true);} if(e.key==='Delete') new bootstrap.Modal($('removeModal')).show(); if(e.key===' ') {e.preventDefault();runAction('start');} if(e.key.toLowerCase()==='p')runAction('pause'); if(e.key.toLowerCase()==='s')runAction('stop'); if(e.key.toLowerCase()==='r')runAction('resume'); if(e.key.toLowerCase()==='m')runAction('move'); });
$('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();}); $('removeModal')?.addEventListener('show.bs.modal',()=>{$('removeCount').textContent=selected.size;$('removeData').checked=true;}); $('confirmRemoveBtn')?.addEventListener('click',async()=>{await runAction('remove',{remove_data:$('removeData').checked});bootstrap.Modal.getInstance($('removeModal'))?.hide();});
$('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true)); $('addModal')?.addEventListener('show.bs.modal',()=>applyDefaultDownloadPath(true));
@@ -1087,6 +1133,32 @@ ${disk.error}`:''}`;
b.classList.add("btn-primary"); b.classList.remove("btn-outline-secondary"); b.classList.add("btn-primary"); b.classList.remove("btn-outline-secondary");
loadTrafficHistory(b.dataset.range||"7d"); loadTrafficHistory(b.dataset.range||"7d");
})); }));
socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();(msg.torrents||[]).forEach(t=>torrents.set(t.hash,t));scheduleRender(true);hideInitialLoader();}); socket.on('torrent_patch',patchRows); socket.on('job_update',()=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');toast(`${msg.action} started`,'secondary');}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action} done`,'success');}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action}: ${msg.error}`,'danger');}); socket.on('rtorrent_error',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} else if(socket.connected){$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg && msg.enabled){ toast(smartQueueToastMessage(msg),'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.applied?.length) toast(`Automations applied ${msg.applied.length} item(s)`,'secondary'); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toast(`Torrent stats: ${msg.error}`,'danger'); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toast(`Startup rTorrent config applied (${msg.result.updated.length})`,'success'); if(msg?.error) toast(`Startup rTorrent config: ${msg.error}`,'danger'); }); socket.on('system_stats',s=>{ const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined; $('statCpuBox')?.classList.toggle('d-none',!usageAvailable);$('statRamBox')?.classList.toggle('d-none',!usageAvailable);$('systemChart')?.classList.toggle('d-none',!usageAvailable); if(usageAvailable){$('statCpu').textContent=s.cpu??'-';$('statRam').textContent=s.ram??'-';drawSystemUsage(s.cpu,s.ram);} $('statVersion').textContent=s.version||'-';$('statDl').textContent=s.down_rate_h||'0 B/s';$('statUl').textContent=s.up_rate_h||'0 B/s';if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h||'0 B/s';if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h||'0 B/s';lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};$('statDlLimit').textContent=s.down_limit_h||'∞';$('statUlLimit').textContent=s.up_limit_h||'∞';$('statTotalDl').textContent=compactTransferText(s.total_down_h);$('statTotalUl').textContent=compactTransferText(s.total_up_h);drawTraffic(s.down_rate,s.up_rate);drawDiskUsage(s.disk);updateSocketStatus(s);applyFooterPreferences();}); socket.on('connect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection is ready. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('disconnect',()=>{ $('connBadge').className='badge text-bg-danger'; $('connBadge').textContent='offline'; setInitialLoader('Waiting for connection...','pyTorrent is not connected yet. The application will open after data is received.'); }); socket.io.on('reconnect_attempt',()=>{ $('connBadge').className='badge text-bg-warning'; $('connBadge').textContent='reconnecting'; setInitialLoader('Reconnecting...','Trying to restore the live connection and load torrent data.'); }); socket.io.on('reconnect',()=>{ if(!hasActiveProfile){ showFirstRunSetup(); return; } $('connBadge').className='badge text-bg-success'; $('connBadge').textContent='online'; setInitialLoader('Loading torrents...','Connection restored. Waiting for the first torrent snapshot.'); socket.emit('select_profile',{profile_id:window.PYTORRENT.activeProfile}); }); socket.on('profile_required',()=>showFirstRunSetup()); socket.on('torrent_snapshot',msg=>{hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();(msg.torrents||[]).forEach(t=>torrents.set(t.hash,t));scheduleRender(true);hideInitialLoader();}); socket.on('torrent_patch',patchRows); socket.on('job_update',()=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');toast(`${msg.action} started`,'secondary');}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action} done`,'success');}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);toast(`${msg.action}: ${msg.error}`,'danger');}); socket.on('rtorrent_error',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} }); socket.on('heartbeat',msg=>{ if(msg.error){$('connBadge').className='badge badge-degraded';$('connBadge').textContent='degraded'; setInitialLoader('Waiting for rTorrent...','rTorrent is not ready yet. Data will appear automatically after it responds.');} else if(socket.connected){$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg && msg.enabled){ toast(smartQueueToastMessage(msg),'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.applied?.length) toast(`Automations applied ${msg.applied.length} item(s)`,'secondary'); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toast(`Torrent stats: ${msg.error}`,'danger'); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toast(`Startup rTorrent config applied (${msg.result.updated.length})`,'success'); if(msg?.error) toast(`Startup rTorrent config: ${msg.error}`,'danger'); }); socket.on('system_stats',s=>{
updateSortHeaders(); applyColumnVisibility(); renderColumnManager(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{}); const usageAvailable=s.usage_available!==false && s.cpu!==undefined && s.ram!==undefined;
$('statCpuBox')?.classList.toggle('d-none',!usageAvailable);
$('statRamBox')?.classList.toggle('d-none',!usageAvailable);
$('systemChart')?.classList.toggle('d-none',!usageAvailable);
if(usageAvailable){
$('statCpu').textContent=s.cpu??'-';
$('statRam').textContent=s.ram??'-';
drawSystemUsage(s.cpu,s.ram);
}
$('statVersion').textContent=s.version||'-';
$('statDl').textContent=s.down_rate_h||'0 B/s';
$('statUl').textContent=s.up_rate_h||'0 B/s';
if($('mobileSpeedDl')) $('mobileSpeedDl').textContent=s.down_rate_h||'0 B/s';
if($('mobileSpeedUl')) $('mobileSpeedUl').textContent=s.up_rate_h||'0 B/s';
lastLimits={down:Number(s.down_limit||0),up:Number(s.up_limit||0)};
$('statDlLimit').textContent=s.down_limit_h||'∞';
$('statUlLimit').textContent=s.up_limit_h||'∞';
$('statTotalDl').textContent=compactTransferText(s.total_down_h);
$('statTotalUl').textContent=compactTransferText(s.total_up_h);
updateSpeedPeaks(s.speed_peaks||{});
updateBrowserSpeedTitle(s.down_rate_h||'0 B/s', s.up_rate_h||'0 B/s');
drawTraffic(s.down_rate,s.up_rate);
drawDiskUsage(s.disk);
updateSocketStatus(s);
applyFooterPreferences();
});
updateSortHeaders(); applyColumnVisibility(); renderColumnManager(); renderFooterPreferences(); applyFooterPreferences(); updateFooterClock(); updateBrowserSpeedTitle(); setInterval(updateFooterClock,1000); scheduleRender(true); if(!hasActiveProfile) renderNoProfileState(); loadLabels().catch(()=>{}); loadRatios().catch(()=>{}); loadSmartQueue().catch(()=>{}); loadAutomations().catch(()=>{}); if(portCheckEnabled) loadPortCheck(false); else renderPortCheck({status:'disabled',enabled:false}); if(hasActiveProfile) applyDefaultDownloadPath(false).catch(()=>{});
})(); })();

View File

@@ -440,6 +440,11 @@ body {
.statusbar b { .statusbar b {
color: var(--bs-body-color); color: var(--bs-body-color);
} }
.speed-peaks {
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.status-limit { .status-limit {
border: 1px solid var(--bs-border-color); border: 1px solid var(--bs-border-color);
background: rgba(var(--bs-secondary-bg-rgb), 0.9); background: rgba(var(--bs-secondary-bg-rgb), 0.9);
@@ -672,6 +677,21 @@ body {
font-weight: 700; font-weight: 700;
text-transform: uppercase; text-transform: uppercase;
} }
.browser-speed-pref {
display: grid;
gap: 0.25rem;
align-content: center;
min-height: 58px;
margin: 0;
padding: 0.55rem 0.75rem 0.55rem 2.6rem;
border: 1px solid var(--bs-border-color);
border-radius: 0.65rem;
background: rgba(var(--bs-secondary-bg-rgb), 0.35);
}
.browser-speed-pref small {
color: var(--bs-secondary-color);
line-height: 1.2;
}
@media (max-width: 640px) { @media (max-width: 640px) {
.preferences-grid { .preferences-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -911,13 +931,6 @@ body.mobile-mode .mobile-card {
align-items: end; align-items: end;
justify-content: flex-start; justify-content: flex-start;
} }
#trafficHistoryChart {
width: 100%;
height: 420px;
border: 1px solid var(--bs-border-color);
border-radius: 0.75rem;
background: var(--bs-body-bg);
}
@media (max-width: 992px) { @media (max-width: 992px) {
.profile-form-grid { .profile-form-grid {
grid-template-columns: 1fr; grid-template-columns: 1fr;

File diff suppressed because one or more lines are too long