diff --git a/pytorrent/__init__.py b/pytorrent/__init__.py index 3d6ec4d..7875d21 100644 --- a/pytorrent/__init__.py +++ b/pytorrent/__init__.py @@ -122,6 +122,8 @@ def create_app() -> Flask: app.register_blueprint(api_bp) register_error_pages(app) init_db() + from .services.speed_peaks import load_cache + load_cache() from .services.auth import install_guards install_guards(app) diff --git a/pytorrent/db.py b/pytorrent/db.py index 67186bf..e0087c1 100644 --- a/pytorrent/db.py +++ b/pytorrent/db.py @@ -39,6 +39,7 @@ CREATE TABLE IF NOT EXISTS user_preferences ( peers_refresh_seconds INTEGER DEFAULT 0, port_check_enabled INTEGER DEFAULT 0, footer_items_json TEXT, + title_speed_enabled INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, 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 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 ( id INTEGER PRIMARY KEY AUTOINCREMENT, 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 font_family TEXT DEFAULT 'default'", "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 is_remote INTEGER DEFAULT 0", "ALTER TABLE jobs ADD COLUMN attempts INTEGER DEFAULT 0", diff --git a/pytorrent/routes/api.py b/pytorrent/routes/api.py index 907e502..3bd559b 100644 --- a/pytorrent/routes/api.py +++ b/pytorrent/routes/api.py @@ -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 ..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 import preferences, rtorrent, torrent_stats +from ..services import preferences, rtorrent, torrent_stats, speed_peaks from ..services.torrent_cache import torrent_cache from ..services.torrent_summary import cached_summary 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["usage_source"] = "local" 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}) except Exception as exc: return jsonify({"ok": False, "error": str(exc)}) @@ -670,6 +672,11 @@ def app_status(): status["scgi"] = rtorrent.scgi_diagnostics(profile) except Exception as exc: 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: 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) diff --git a/pytorrent/services/preferences.py b/pytorrent/services/preferences.py index e5e9bfa..37be3fd 100644 --- a/pytorrent/services/preferences.py +++ b/pytorrent/services/preferences.py @@ -167,6 +167,7 @@ def save_preferences(data: dict, user_id: int | None = None): peers_refresh_seconds = data.get("peers_refresh_seconds") port_check_enabled = data.get("port_check_enabled") footer_items_json = data.get("footer_items_json") + title_speed_enabled = data.get("title_speed_enabled") with connect() as conn: now = utcnow() 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)) 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)) + 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: # 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) diff --git a/pytorrent/services/speed_peaks.py b/pytorrent/services/speed_peaks.py new file mode 100644 index 0000000..0b1b50d --- /dev/null +++ b/pytorrent/services/speed_peaks.py @@ -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))) diff --git a/pytorrent/services/websocket.py b/pytorrent/services/websocket.py index d89083e..830ce8f 100644 --- a/pytorrent/services/websocket.py +++ b/pytorrent/services/websocket.py @@ -7,7 +7,7 @@ from ..config import POLL_INTERVAL from .preferences import active_profile, get_profile from .torrent_cache import torrent_cache 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: @@ -59,6 +59,8 @@ def register_socketio_handlers(socketio): status["usage_available"] = True 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)) + # 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) heartbeat["ok"] = True except Exception as exc: diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index de4f1fc..0da60e9 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -17,10 +17,13 @@ let portCheckEnabled = !!Number(window.PYTORRENT?.portCheckEnabled || 0); let bootstrapTheme = window.PYTORRENT?.bootstrapTheme || "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 = [ ["cpu", "CPU"], ["ram", "RAM"], ["usage_chart", "CPU/RAM chart"], ["disk", "Disk"], ["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"] ]; 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 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"); } } + 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 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'); } 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(){ const el=$('statClock'); 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(); 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 peaks=st.speed_peaks||{}, peakSession=peaks.session||{}, peakAllTime=peaks.all_time||{}; 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('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('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('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`:'-'), @@ -927,7 +973,7 @@ 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('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'); }); $('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)); @@ -1087,6 +1133,32 @@ ${disk.error}`:''}`; b.classList.add("btn-primary"); b.classList.remove("btn-outline-secondary"); 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();}); - 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(()=>{}); + 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); + 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(()=>{}); })(); diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index ddb022d..209b75a 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -440,6 +440,11 @@ body { .statusbar b { color: var(--bs-body-color); } +.speed-peaks { + display: inline-flex; + align-items: center; + gap: 0.25rem; +} .status-limit { border: 1px solid var(--bs-border-color); background: rgba(var(--bs-secondary-bg-rgb), 0.9); @@ -672,6 +677,21 @@ body { font-weight: 700; 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) { .preferences-grid { grid-template-columns: 1fr; @@ -911,13 +931,6 @@ body.mobile-mode .mobile-card { align-items: end; 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) { .profile-form-grid { grid-template-columns: 1fr; diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index 4afc7d3..25a9be4 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -103,7 +103,7 @@ @@ -148,7 +148,7 @@ - + @@ -183,6 +183,6 @@
- +