diff --git a/pytorrent/services/startup_config.py b/pytorrent/services/startup_config.py
index a6cc305..42afc7f 100644
--- a/pytorrent/services/startup_config.py
+++ b/pytorrent/services/startup_config.py
@@ -61,7 +61,7 @@ def _apply_profile(socketio, profile: dict) -> None:
return
_applied_profiles.add(profile_id)
_log_status(profile, "applied", "Saved rTorrent startup config overrides applied", result=result)
- socketio.emit("rtorrent_config_applied", {"profile_id": profile_id, "result": result})
+ socketio.emit("rtorrent_config_applied", {"profile_id": profile_id, "result": result}, to=f"profile:{int(profile_id)}")
def schedule_startup_config_apply(socketio, delay_seconds: int = 60, retry_seconds: int = 30, max_wait_seconds: int = 3600) -> None:
@@ -97,7 +97,7 @@ def schedule_startup_config_apply(socketio, delay_seconds: int = 60, retry_secon
action="rtorrent_config",
details={"error": str(exc)},
)
- socketio.emit("rtorrent_config_applied", {"ok": False, "error": str(exc)})
+ socketio.emit("rtorrent_config_applied", {"ok": False, "profile_id": int(profile_id or 0), "error": str(exc)}, to=f"profile:{int(profile_id or 0)}" if profile_id else None)
socketio.sleep(max(5, int(retry_seconds)))
socketio.start_background_task(runner)
diff --git a/pytorrent/services/websocket.py b/pytorrent/services/websocket.py
index 7dd9c8d..60b9992 100644
--- a/pytorrent/services/websocket.py
+++ b/pytorrent/services/websocket.py
@@ -27,8 +27,9 @@ def _poller_profiles() -> list[dict]:
def emit_profile_event(socketio, event: str, payload: dict, profile_id: int) -> None:
- target = _profile_room(profile_id) if auth.enabled() else None
- socketio.emit(event, payload, to=target) if target else socketio.emit(event, payload)
+ # Note: Profile-scoped events always go to the selected profile room, even when authentication is disabled.
+ scoped_payload = {**(payload or {}), "profile_id": int(profile_id)}
+ socketio.emit(event, scoped_payload, to=_profile_room(profile_id))
def _emit_profile(socketio, event: str, payload: dict, profile_id: int) -> None:
@@ -51,7 +52,7 @@ def _run_slow_profile_tasks(socketio, profile: dict, profile_id: int) -> None:
profile_user_id = int(profile.get("user_id") or default_user_id())
try:
try:
- torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id) if auth.enabled() else None)
+ torrent_stats.queue_refresh(socketio, profile, force=False, room=_profile_room(profile_id))
except Exception as exc:
_emit_profile(socketio, "torrent_stats_update", {"ok": False, "profile_id": profile_id, "error": str(exc)}, profile_id)
try:
diff --git a/pytorrent/services/workers.py b/pytorrent/services/workers.py
index 5104e4a..56c8dcc 100644
--- a/pytorrent/services/workers.py
+++ b/pytorrent/services/workers.py
@@ -42,8 +42,8 @@ def _emit(name: str, payload: dict):
if not _socketio:
return
profile_id = payload.get("profile_id")
- if auth.enabled() and profile_id:
- # Note: Job/socket events are sent only to clients joined to the affected profile room.
+ if profile_id:
+ # Note: Job/socket events are profile-room scoped so modals and toasts do not leak between rTorrent profiles.
_socketio.emit(name, payload, to=f"profile:{int(profile_id)}")
else:
_socketio.emit(name, payload)
@@ -359,9 +359,9 @@ def _emit_torrent_refresh(profile: dict, action_name: str) -> None:
profile_id = int(profile["id"])
if diff.get("ok"):
rows = torrent_cache.snapshot(profile_id)
- _emit("torrent_patch", {**diff, "summary": cached_summary(profile_id, rows, force=True)})
+ _emit("torrent_patch", {**diff, "profile_id": profile_id, "summary": cached_summary(profile_id, rows, force=True)})
else:
- _emit("rtorrent_error", diff)
+ _emit("rtorrent_error", {**diff, "profile_id": profile_id})
except Exception as exc:
# Note: A failed live refresh must not change the already completed job result.
_emit("rtorrent_error", {"profile_id": int(profile.get("id") or 0), "error": str(exc)})
diff --git a/pytorrent/static/js/initialSnapshot.js b/pytorrent/static/js/initialSnapshot.js
index e08881f..3bc92a3 100644
--- a/pytorrent/static/js/initialSnapshot.js
+++ b/pytorrent/static/js/initialSnapshot.js
@@ -1 +1 @@
-export const initialSnapshotSource = " async function loadInitialSnapshotFallback(reason=''){\n if(initialLoaderDone) return;\n try{\n const profilesResp = await fetch('/api/profiles', {cache:'no-store'});\n const profilesJson = await profilesResp.json().catch(()=>({ok:false}));\n const active = profilesJson.active || null;\n if(!active){ showFirstRunSetup(); return; }\n const torrentsResp = await fetch('/api/torrents', {cache:'no-store'});\n const j = await torrentsResp.json().catch(()=>({ok:false,error:'Invalid /api/torrents response'}));\n if(j.ok === false) throw new Error(j.error || 'Torrent API failed');\n const rows = j.torrents || [];\n if(j.error && !rows.length){ renderRtorrentStartingState(j.error, true); hideInitialLoader(); return; }\n clearRtorrentStartingState();\n hasTorrentSnapshot = true;\n torrentSummary = j.summary || null;\n torrents.clear();\n rows.forEach(t=>torrents.set(t.hash,t));\n if(j.speed_status) applyLiveSpeedStats(j.speed_status); else updateBrowserSpeedTitle();\n scheduleRender(true);\n scheduleTrackerSummary(true);\n hideInitialLoader();\n }catch(e){\n setInitialLoader('Waiting for rTorrent...', (reason ? reason + ': ' : '') + (e.message || 'Unable to load torrent data.'));\n renderRtorrentStartingState(e.message || reason || 'Unable to load torrent data.', true);\n hideInitialLoader();\n }\n }\n async function refreshTorrentSnapshot(reason='') {\n if(!hasActiveProfile) return;\n try {\n const response = await fetch('/api/torrents', {cache:'no-store'});\n const data = await response.json().catch(() => ({ok:false,error:'Invalid /api/torrents response'}));\n if(data.ok === false) throw new Error(data.error || 'Torrent API failed');\n const rows = data.torrents || [];\n if(data.error && !rows.length) {\n renderRtorrentStartingState(data.error, true);\n return;\n }\n clearRtorrentStartingState();\n hasTorrentSnapshot = true;\n torrentSummary = data.summary || null;\n torrents.clear();\n selected.clear();\n rows.forEach(t => torrents.set(t.hash, t));\n if(data.speed_status) applyLiveSpeedStats(data.speed_status);\n else updateBrowserSpeedTitle();\n scheduleRender(true);\n scheduleTrackerSummary(false);\n } catch (error) {\n if(reason) recordNotification('error', 'Torrent refresh failed', error.message || reason);\n }\n }\n setTimeout(()=>loadInitialSnapshotFallback('Socket fallback'), 4000);\n if(!socket || !socket.io || typeof socket.on !== 'function') setTimeout(()=>loadInitialSnapshotFallback('Socket.IO unavailable'), 200);\n 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=>{const rows=msg.torrents||[]; if(msg.error && !rows.length){ renderRtorrentStartingState(msg.error, true); return; } clearRtorrentStartingState(); hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();rows.forEach(t=>torrents.set(t.hash,t));if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle();scheduleRender(true);scheduleTrackerSummary(true);hideInitialLoader();}); socket.on('torrent_patch',msg=>{patchRows(msg);scheduleTrackerSummary(false);}); socket.on('torrent_live_patch',msg=>{applyLiveTorrentStats(msg);}); socket.on('job_update',msg=>{ if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); if(['done','failed','cancelled'].includes(String(msg?.status||''))) setTimeout(()=>refreshTorrentSnapshot('job_update'), 350); }); socket.on('operation_started',msg=>{setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');if(shouldShowOperationToast(msg)) toastMessage('toast.operationStarted','secondary',{action:msg.action});}); socket.on('operation_finished',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);applyActionCompletionState(msg.action,msg.hashes||[]);refreshTorrentSnapshot('operation_finished');if(shouldShowOperationToast(msg)) toastMessage('toast.operationDone','success',{action:msg.action});}); socket.on('operation_failed',msg=>{setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);if(shouldShowOperationToast(msg)) toastMessage('toast.operationFailed','danger',{action:msg.action,error:msg.error});}); socket.on('rtorrent_error',msg=>{ if(msg.error){ recordNotification('error','rTorrent error',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.'); scheduleRtorrentStartingState(msg.error);} }); 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.'); scheduleRtorrentStartingState(msg.error);} else if(socket.connected){clearRtorrentStartingState();$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(msg?.enabled && !msg.cooldown_skipped) recordNotification('queue','Smart Queue decision',smartQueueToastMessage(msg)); if(msg?.cooldown_remaining_seconds!==undefined) updateCooldownBadge('smartCooldownBadge', Number(msg.cooldown_remaining_seconds||0)); if(msg && msg.enabled && !msg.cooldown_skipped && smartQueueToastsEnabled){ toast(smartQueueToastMessage(msg),'secondary'); } }); socket.on('automation_update',msg=>{ if(msg?.error) recordNotification('error','Automation error',msg.error); if(msg?.applied?.length) recordNotification('info','Automation applied',`${msg.applied.length} item(s)`); if(msg?.applied?.length && automationToastsEnabled) toastMessage('toast.automationsApplied','secondary',{count:msg.applied.length}); }); socket.on('torrent_stats_update',msg=>{ if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toastMessage('toast.torrentStatsError','danger',{error:msg.error}); } }); socket.on('rtorrent_config_applied',msg=>{ if(msg?.result?.updated?.length) toastMessage('toast.startupConfigApplied','success',{count:msg.result.updated.length}); if(msg?.error) toastMessage('toast.startupConfigFailed','danger',{error:msg.error}); }); socket.on('download_plan_update',msg=>{ if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed||msg.pause_reason)) recordNotification('planner','Planner action',`paused ${msg.paused||0}, resumed ${msg.resumed||0}${msg.pause_reason?`, ${msg.pause_reason}`:''}`); if(msg?.settings) fillPlanner(msg.settings); if(msg?.preview) renderPlannerPreview(msg.preview); else if(msg?.matched_rule) renderPlannerPreview(msg); if(msg?.history) renderPlannerHistory(msg.history); if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed)) toastMessage('toast.plannerSocketResult','secondary',{paused:msg.paused,resumed:msg.resumed,dryRun:msg.dry_run}); }); socket.on('poller_settings',msg=>fillPoller(msg?.settings||{},msg?.runtime||{}));\n";
+export const initialSnapshotSource = " async function loadInitialSnapshotFallback(reason=''){\n if(initialLoaderDone) return;\n try{\n const profilesResp = await fetch('/api/profiles', {cache:'no-store'});\n const profilesJson = await profilesResp.json().catch(()=>({ok:false}));\n const active = profilesJson.active || null;\n if(!active){ showFirstRunSetup(); return; }\n const torrentsResp = await fetch('/api/torrents', {cache:'no-store'});\n const j = await torrentsResp.json().catch(()=>({ok:false,error:'Invalid /api/torrents response'}));\n if(j.ok === false) throw new Error(j.error || 'Torrent API failed');\n const rows = j.torrents || [];\n if(j.error && !rows.length){ renderRtorrentStartingState(j.error, true); hideInitialLoader(); return; }\n clearRtorrentStartingState();\n hasTorrentSnapshot = true;\n torrentSummary = j.summary || null;\n torrents.clear();\n rows.forEach(t=>torrents.set(t.hash,t));\n if(j.speed_status) applyLiveSpeedStats(j.speed_status); else updateBrowserSpeedTitle();\n scheduleRender(true);\n scheduleTrackerSummary(true);\n hideInitialLoader();\n }catch(e){\n setInitialLoader('Waiting for rTorrent...', (reason ? reason + ': ' : '') + (e.message || 'Unable to load torrent data.'));\n renderRtorrentStartingState(e.message || reason || 'Unable to load torrent data.', true);\n hideInitialLoader();\n }\n }\n async function refreshTorrentSnapshot(reason='') {\n if(!hasActiveProfile) return;\n try {\n const response = await fetch('/api/torrents', {cache:'no-store'});\n const data = await response.json().catch(() => ({ok:false,error:'Invalid /api/torrents response'}));\n if(data.ok === false) throw new Error(data.error || 'Torrent API failed');\n const rows = data.torrents || [];\n if(data.error && !rows.length) {\n renderRtorrentStartingState(data.error, true);\n return;\n }\n clearRtorrentStartingState();\n hasTorrentSnapshot = true;\n torrentSummary = data.summary || null;\n torrents.clear();\n selected.clear();\n rows.forEach(t => torrents.set(t.hash, t));\n if(data.speed_status) applyLiveSpeedStats(data.speed_status);\n else updateBrowserSpeedTitle();\n scheduleRender(true);\n scheduleTrackerSummary(false);\n } catch (error) {\n if(reason) recordNotification('error', 'Torrent refresh failed', error.message || reason);\n }\n }\n setTimeout(()=>loadInitialSnapshotFallback('Socket fallback'), 4000);\n if(!socket || !socket.io || typeof socket.on !== 'function') setTimeout(()=>loadInitialSnapshotFallback('Socket.IO unavailable'), 200);\n 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=>{ if(!isActiveProfilePayload(msg)) return; const rows=msg.torrents||[]; if(msg.error && !rows.length){ renderRtorrentStartingState(msg.error, true); return; } clearRtorrentStartingState(); hasTorrentSnapshot=true;torrentSummary=msg.summary||null;torrents.clear();rows.forEach(t=>torrents.set(t.hash,t));if(msg.speed_status) applyLiveSpeedStats(msg.speed_status); else updateBrowserSpeedTitle();scheduleRender(true);scheduleTrackerSummary(true);hideInitialLoader();}); socket.on('torrent_patch',msg=>{ if(!isActiveProfilePayload(msg)) return; patchRows(msg);scheduleTrackerSummary(false);}); socket.on('torrent_live_patch',msg=>{ if(!isActiveProfilePayload(msg)) return; applyLiveTorrentStats(msg);}); socket.on('job_update',msg=>{ if(!isActiveProfilePayload(msg)) return; if(document.body.classList.contains('modal-open')) loadJobs().catch(()=>{}); if(['done','failed','cancelled'].includes(String(msg?.status||''))) setTimeout(()=>refreshTorrentSnapshot('job_update'), 350); }); socket.on('operation_started',msg=>{ if(!isActiveProfilePayload(msg)) return; setBusy(true);markTorrentOperation(msg.hashes||[],msg.action,msg.job_id,'running');if(shouldShowOperationToast(msg)) toastMessage('toast.operationStarted','secondary',{action:msg.action});}); socket.on('operation_finished',msg=>{ if(!isActiveProfilePayload(msg)) return; setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);applyActionCompletionState(msg.action,msg.hashes||[]);refreshTorrentSnapshot('operation_finished');if(shouldShowOperationToast(msg)) toastMessage('toast.operationDone','success',{action:msg.action});}); socket.on('operation_failed',msg=>{ if(!isActiveProfilePayload(msg)) return; setBusy(false);clearJobOperation(msg.job_id,msg.hashes||[]);if(shouldShowOperationToast(msg)) toastMessage('toast.operationFailed','danger',{action:msg.action,error:msg.error});}); socket.on('rtorrent_error',msg=>{ if(!isActiveProfilePayload(msg)) return; if(msg.error){ recordNotification('error','rTorrent error',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.'); scheduleRtorrentStartingState(msg.error);} }); socket.on('heartbeat',msg=>{ if(!isActiveProfilePayload(msg)) return; 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.'); scheduleRtorrentStartingState(msg.error);} else if(socket.connected){clearRtorrentStartingState();$('connBadge').className='badge text-bg-success';$('connBadge').textContent='online';} }); socket.on('smart_queue_update',msg=>{ if(!isActiveProfilePayload(msg)) return; if(msg?.enabled && !msg.cooldown_skipped) recordNotification('queue','Smart Queue decision',smartQueueToastMessage(msg)); if(msg?.cooldown_remaining_seconds!==undefined) updateCooldownBadge('smartCooldownBadge', Number(msg.cooldown_remaining_seconds||0)); if(msg && msg.enabled && !msg.cooldown_skipped && smartQueueToastsEnabled){ toast(smartQueueToastMessage(msg),'secondary'); } }); socket.on('automation_update',msg=>{ if(!isActiveProfilePayload(msg)) return; if(msg?.error) recordNotification('error','Automation error',msg.error); if(msg?.applied?.length) recordNotification('info','Automation applied',`${msg.applied.length} item(s)`); if(msg?.applied?.length && automationToastsEnabled) toastMessage('toast.automationsApplied','secondary',{count:msg.applied.length}); }); socket.on('torrent_stats_update',msg=>{ if(!isActiveProfilePayload(msg)) return; if(msg?.stats){ renderTorrentStats(msg.stats); } else if(msg?.error && $('toolTorrentStats') && !$('toolTorrentStats').classList.contains('d-none')){ toastMessage('toast.torrentStatsError','danger',{error:msg.error}); } }); socket.on('rtorrent_config_applied',msg=>{ if(!isActiveProfilePayload(msg)) return; if(msg?.result?.updated?.length) toastMessage('toast.startupConfigApplied','success',{count:msg.result.updated.length}); if(msg?.error) toastMessage('toast.startupConfigFailed','danger',{error:msg.error}); }); socket.on('download_plan_update',msg=>{ if(!isActiveProfilePayload(msg)) return; if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed||msg.pause_reason)) recordNotification('planner','Planner action',`paused ${msg.paused||0}, resumed ${msg.resumed||0}${msg.pause_reason?`, ${msg.pause_reason}`:''}`); if(msg?.settings) fillPlanner(msg.settings); if(msg?.preview) renderPlannerPreview(msg.preview); else if(msg?.matched_rule) renderPlannerPreview(msg); if(msg?.history) renderPlannerHistory(msg.history); if(msg?.enabled && (msg.paused||msg.resumed||msg.limits_changed)) toastMessage('toast.plannerSocketResult','secondary',{paused:msg.paused,resumed:msg.resumed,dryRun:msg.dry_run}); }); socket.on('poller_settings',msg=>{ if(!isActiveProfilePayload(msg)) return; fillPoller(msg?.settings||{},msg?.runtime||{}); });\n";
diff --git a/pytorrent/static/js/profiles.js b/pytorrent/static/js/profiles.js
index adf8829..e53e096 100644
--- a/pytorrent/static/js/profiles.js
+++ b/pytorrent/static/js/profiles.js
@@ -1 +1 @@
-export const profilesSource = " async function activeProfileForSettings(){\n const j=await (await fetch('/api/profiles')).json();\n return j.active || (j.profiles||[])[0] || null;\n }\n function fillJobSettings(profile){\n if(!profile) return;\n if($('jobHeavyParallel')) $('jobHeavyParallel').value=profile.max_parallel_jobs||5;\n if($('jobLightParallel')) $('jobLightParallel').value=profile.light_parallel_jobs||4;\n if($('jobLightTimeout')) $('jobLightTimeout').value=profile.light_job_timeout_seconds||300;\n if($('jobHeavyTimeout')) $('jobHeavyTimeout').value=profile.heavy_job_timeout_seconds||7200;\n if($('jobPendingTimeout')) $('jobPendingTimeout').value=profile.pending_job_timeout_seconds||900;\n if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=profile.name?`Active profile: ${profile.name}`:'';\n }\n async function loadJobSettings(){\n try{\n const profile=await activeProfileForSettings();\n if(!profile){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent='No active profile.'; return; }\n fillJobSettings(profile);\n }catch(e){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=e.message; }\n }\n function jobSettingsPayload(profile){\n return {\n name:profile.name,\n scgi_url:profile.scgi_url,\n timeout_seconds:profile.timeout_seconds||5,\n max_parallel_jobs:$('jobHeavyParallel')?.value||5,\n light_parallel_jobs:$('jobLightParallel')?.value||4,\n light_job_timeout_seconds:$('jobLightTimeout')?.value||300,\n heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,\n pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,\n is_remote:!!profile.is_remote,\n is_default:!!profile.is_default\n };\n }\n async function saveJobSettings(){\n const btn=$('saveJobSettingsBtn');\n buttonBusy(btn,true);\n try{\n const profile=await activeProfileForSettings();\n if(!profile) throw new Error('No active profile');\n const j=await post(`/api/profiles/${profile.id}`,jobSettingsPayload(profile),'PUT');\n fillJobSettings(j.profile||profile);\n await refreshProfiles();\n toast('Job settings saved','success');\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n function markActiveProfileRow(id){\n // Note: Keeps the active rTorrent profile frame in sync immediately after switching, before diagnostics refresh finishes.\n const activeId=String(id||'');\n document.querySelectorAll('#profileList .profile-row').forEach(row=>{\n const isActive=String(row.dataset.profileId||'')===activeId;\n row.classList.toggle('active', isActive);\n row.setAttribute('aria-current', isActive ? 'true' : 'false');\n const badge=row.querySelector('[data-active-profile-badge]');\n if(badge) badge.classList.toggle('d-none', !isActive);\n });\n }\n function profileDiagnosticStatusClass(status){\n // Note: rTorrent profile badges reuse Bootstrap colors and the same normal/slow/error idea as the poller panel.\n const value=String(status||'unknown').toLowerCase();\n if(value==='normal' || value==='online') return 'success';\n if(value==='slow' || value==='slowdown') return 'warning';\n if(value==='error' || value==='recovery') return 'danger';\n return 'secondary';\n }\n function profileDiagnosticStatusLabel(status){\n const value=String(status||'unknown').toLowerCase();\n return value==='online' ? 'normal' : value;\n }\n async function refreshProfiles(){ const j=await (await fetch('/api/profiles')).json(); profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); const active=String(j.active?.id ?? activeProfileId ?? ''); const rows=j.profiles||[]; const statusMap=new Map(); try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){} $('profileList').innerHTML=rows.map(p=>{ const d=statusMap.get(String(p.id))||{}; const st=profileDiagnosticStatusLabel(d.status || 'unknown'); const cls=profileDiagnosticStatusClass(st); const response=d.response_time_ms?` · ${esc(d.response_time_ms)} ms`:''; const threshold=d.slow_threshold_ms?` · slow > ${esc(d.slow_threshold_ms)} ms`:''; const isActive=String(p.id)===active; return `
${esc(p.name)} active ${p.is_remote?\"remote\":''} ${esc(st)}${esc(p.scgi_url)} · heavy ${esc(p.max_parallel_jobs||5)} · light ${esc(p.light_parallel_jobs||4)} · poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold} `; }).join('')||'No profiles.'; }\n function profileFormPayload(){ return {id:$('profileId')?.value||null,name:$('profileName')?.value||'',scgi_url:$('profileUrl')?.value||'',timeout_seconds:$('profileTimeout')?.value||5,max_parallel_jobs:$('profileParallel')?.value||5,light_parallel_jobs:$('jobLightParallel')?.value||4,light_job_timeout_seconds:$('jobLightTimeout')?.value||300,heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,is_remote:$('profileRemote')?.checked}; }\n function renderProfileDiagnostics(d={}){ const box=$('profileDiagnosticsResult'); if(!box) return; const status=profileDiagnosticStatusLabel(d.status || (d.ok?'normal':'error')); const cls=profileDiagnosticStatusClass(status); const paths=d.base_paths||{}; const wp=d.write_permissions||{}; const disk=d.free_disk||{}; const firstDisk=Object.values(disk)[0]||{}; const cards=[['Status',`${esc(status)}`],['rTorrent',esc(d.version||'-')],['Library',esc(d.library_version||'-')],['Response',d.response_time_ms!=null?`${esc(d.response_time_ms)} ms`:'-'],['Slow threshold',d.slow_threshold_ms!=null?`${esc(d.slow_threshold_ms)} ms`:'-'],['Default path',esc(paths.default_directory||'-')],['CWD',esc(paths.cwd||'-')],['Write',esc(Object.values(wp)[0]||'-')],['Free disk',esc(firstDisk.free_h||firstDisk.error||'-')]]; box.classList.remove('text-muted'); box.innerHTML=`${cards.map(([k,v])=>`
${esc(k)}${v}
`).join('')}
${d.error?`${esc(d.error)}
`:''}`; }\n async function testProfilePayload(payload=null){ const p=payload||profileFormPayload(); const res=await post('/api/profiles/test', p); renderProfileDiagnostics(res.diagnostics||{}); return res.diagnostics||{}; }\n\n function resetProfileForm(){ if($('profileId')) $('profileId').value=''; if($('profileName')) $('profileName').value=''; if($('profileUrl')) $('profileUrl').value=''; if($('profileTimeout')) $('profileTimeout').value='5'; if($('profileParallel')) $('profileParallel').value='5'; if($('profileRemote')) $('profileRemote').checked=false; if($('profileFormTitle')) $('profileFormTitle').textContent='Add profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Add profile'; $('cancelProfileEditBtn')?.classList.add('d-none'); }\n function editProfileForm(profile){ if(!profile) return; if($('profileId')) $('profileId').value=profile.id; if($('profileName')) $('profileName').value=profile.name||''; if($('profileUrl')) $('profileUrl').value=profile.scgi_url||''; if($('profileTimeout')) $('profileTimeout').value=profile.timeout_seconds||5; if($('profileParallel')) $('profileParallel').value=profile.max_parallel_jobs||5; if($('profileRemote')) $('profileRemote').checked=!!profile.is_remote; fillJobSettings(profile); if($('profileFormTitle')) $('profileFormTitle').textContent='Edit rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }\n async function activateProfileAndRefresh(id, label=''){\n // Note: Profile activation now refreshes all profile-scoped client state without requiring a browser reload.\n if(!id) return;\n setBusy(true, 'Switching profile...');\n try{\n await post(`/api/profiles/${id}/activate`,{});\n activeProfileId=id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(id);\n markActiveProfileRow(id);\n if($('activeProfileName') && label) $('activeProfileName').textContent=label;\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n defaultDownloadPath=null;\n lastUserDiskFetchAt=0;\n userDiskFetchSeq += 1;\n userDiskFetchInFlight=false;\n clearRtorrentStartingState();\n hasTorrentSnapshot=false;\n torrentSummary=null;\n trackerSummary={hashes:{}, trackers:[], scanned:0, errors:[]};\n trackerSummaryStatus='idle';\n trackerSummarySignature='';\n torrents.clear();\n selected.clear();\n selectedHash=null;\n scheduleRender(true);\n await loadPreferences().catch(()=>{});\n await Promise.allSettled([\n refreshProfiles(),\n applyDefaultDownloadPath(true),\n refreshUserDiskUsage(true),\n loadSmartQueue(),\n loadDownloadPlanner(),\n loadPollerSettings(),\n ]);\n socket.emit('select_profile',{profile_id:Number(id)});\n toast('Profile switched','success');\n }catch(e){\n toast(e.message||'Profile switch failed','danger');\n }finally{\n setBusy(false);\n }\n }\n\n // Note: The rTorrent list lives in Tools modal; refresh it when that modal is shown instead of referencing a missing modal id.\n $('profilePickerModal')?.addEventListener('show.bs.modal',async()=>{\n try{\n const j=await (await fetch('/api/profiles')).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n }); $('profileList')?.addEventListener('click',async e=>{const btn=e.target.closest('[data-del-profile],[data-use-profile],[data-edit-profile],[data-test-saved-profile]'); const del=btn?.dataset.delProfile,use=btn?.dataset.useProfile,edit=btn?.dataset.editProfile,test=btn?.dataset.testSavedProfile;if(test){ const oldHtml=btn.innerHTML; btn.disabled=true; btn.innerHTML=' testing'; const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='Testing saved profile...
'; try{ const r=await (await fetch(`/api/profiles/${test}/diagnostics`)).json(); renderProfileDiagnostics(r.diagnostics||{}); }catch(e){ if(box) box.innerHTML=` ${esc(e.message)}
`; toast(e.message,'danger'); } finally{ btn.disabled=false; btn.innerHTML=oldHtml; } return; } if(edit){editProfileForm(profileCache.get(String(edit)));return;} if(del){setBusy(true);await fetch(`/api/profiles/${del}`,{method:'DELETE'});setBusy(false);refreshProfiles();location.reload();} if(use){await activateProfileAndRefresh(use, profileCache.get(String(use))?.name || 'rTorrent');}}); $('cancelProfileEditBtn')?.addEventListener('click',resetProfileForm); $('testProfileBtn')?.addEventListener('click',async()=>{ const btn=$('testProfileBtn'); const oldHtml=btn?.innerHTML; if(btn){ btn.disabled=true; btn.innerHTML=' Testing SCGI...'; } const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='Testing SCGI connection...
'; setBusy(true); try{ const d=await testProfilePayload(); toast(d.ok?'SCGI test OK':'SCGI test failed', d.ok?'success':'danger'); }catch(e){ toast(e.message,'danger'); if(box) box.innerHTML=` ${esc(e.message)}
`; } finally{setBusy(false); if(btn){ btn.disabled=false; btn.innerHTML=oldHtml||' Test SCGI'; }} }); $('profileExportBtn')?.addEventListener('click',async()=>{ const j=await (await fetch('/api/profiles/export')).json(); const blob=new Blob([JSON.stringify(j,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='pytorrent-profiles.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); }); $('profileImportBtn')?.addEventListener('click',()=>$('profileImportFile')?.click()); $('profileImportFile')?.addEventListener('change',async e=>{ const file=e.target.files?.[0]; if(!file) return; try{ const payload=JSON.parse(await file.text()); await post('/api/profiles/import',payload); toast('Profiles imported','success'); refreshProfiles(); }catch(err){ toast(err.message,'danger'); } e.target.value=''; }); $('saveProfileBtn')?.addEventListener('click',async()=>{setBusy(true);const id=$('profileId')?.value;const payload=profileFormPayload();const j=await post(id?`/api/profiles/${id}`:'/api/profiles',payload,id?'PUT':'POST').catch(e=>toast(e.message,'danger'));setBusy(false);if(j?.profile)location.reload();}); $('saveJobSettingsBtn')?.addEventListener('click',saveJobSettings); $('reloadJobSettingsBtn')?.addEventListener('click',loadJobSettings); $('profileSelect')?.addEventListener('change',async e=>{const id=e.target.value;if(!id)return;const opt=e.target.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');}); $('profilePickerUseBtn')?.addEventListener('click',async()=>{const select=$('profileSelect');const id=select?.value;if(!id)return;const opt=select.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');});\n // Note: Opens the existing rTorrent form directly from the empty first-run state.\n document.addEventListener('click',e=>{ if(e.target.closest('#setupProfileBtn')){ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); setTimeout(()=>$('profileName')?.focus(),150); return; } if(e.target.closest('#chooseProfileBtn')){ openProfilePicker(); } });\n function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},queued:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `Select an rTorrent profile.${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open. |
`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `Select an rTorrent profile.Choose a profile to load torrents.
`;\n if($('detailPane')) $('detailPane').innerHTML = 'Choose an rTorrent profile to load details.';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n new bootstrap.Modal($('profilePickerModal')).show();\n }\n\n // Note: On trusted auth-bypass entry, existing profiles are not auto-selected; the visitor must choose the target profile.\n async function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n let profiles=[];\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n if(j.active?.id){\n activeProfileId=j.active.id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(j.active.id);\n return;\n }\n profiles=j.profiles||[];\n }catch(e){}\n $('connBadge').className='badge text-bg-warning';\n if(profiles.length){\n $('connBadge').textContent='select profile';\n setInitialLoader('Select rTorrent profile','Choose which configured rTorrent profile to open.');\n renderProfileSelectionState(profiles.length);\n hideInitialLoader();\n setTimeout(()=>openProfilePicker(), 120);\n return;\n }\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n";
+export const profilesSource = " async function activeProfileForSettings(){\n const j=await (await fetch('/api/profiles')).json();\n return j.active || (j.profiles||[])[0] || null;\n }\n function fillJobSettings(profile){\n if(!profile) return;\n if($('jobHeavyParallel')) $('jobHeavyParallel').value=profile.max_parallel_jobs||5;\n if($('jobLightParallel')) $('jobLightParallel').value=profile.light_parallel_jobs||4;\n if($('jobLightTimeout')) $('jobLightTimeout').value=profile.light_job_timeout_seconds||300;\n if($('jobHeavyTimeout')) $('jobHeavyTimeout').value=profile.heavy_job_timeout_seconds||7200;\n if($('jobPendingTimeout')) $('jobPendingTimeout').value=profile.pending_job_timeout_seconds||900;\n if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=profile.name?`Active profile: ${profile.name}`:'';\n }\n async function loadJobSettings(){\n try{\n const profile=await activeProfileForSettings();\n if(!profile){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent='No active profile.'; return; }\n fillJobSettings(profile);\n }catch(e){ if($('jobSettingsProfileName')) $('jobSettingsProfileName').textContent=e.message; }\n }\n function jobSettingsPayload(profile){\n return {\n name:profile.name,\n scgi_url:profile.scgi_url,\n timeout_seconds:profile.timeout_seconds||5,\n max_parallel_jobs:$('jobHeavyParallel')?.value||5,\n light_parallel_jobs:$('jobLightParallel')?.value||4,\n light_job_timeout_seconds:$('jobLightTimeout')?.value||300,\n heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,\n pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,\n is_remote:!!profile.is_remote,\n is_default:!!profile.is_default\n };\n }\n async function saveJobSettings(){\n const btn=$('saveJobSettingsBtn');\n buttonBusy(btn,true);\n try{\n const profile=await activeProfileForSettings();\n if(!profile) throw new Error('No active profile');\n const j=await post(`/api/profiles/${profile.id}`,jobSettingsPayload(profile),'PUT');\n fillJobSettings(j.profile||profile);\n await refreshProfiles();\n toast('Job settings saved','success');\n }catch(e){ toast(e.message,'danger'); }\n finally{ buttonBusy(btn,false); }\n }\n function markActiveProfileRow(id){\n // Note: Keeps the active rTorrent profile frame in sync immediately after switching, before diagnostics refresh finishes.\n const activeId=String(id||'');\n document.querySelectorAll('#profileList .profile-row').forEach(row=>{\n const isActive=String(row.dataset.profileId||'')===activeId;\n row.classList.toggle('active', isActive);\n row.setAttribute('aria-current', isActive ? 'true' : 'false');\n const badge=row.querySelector('[data-active-profile-badge]');\n if(badge) badge.classList.toggle('d-none', !isActive);\n });\n }\n function profileDiagnosticStatusClass(status){\n // Note: rTorrent profile badges reuse Bootstrap colors and the same normal/slow/error idea as the poller panel.\n const value=String(status||'unknown').toLowerCase();\n if(value==='normal' || value==='online') return 'success';\n if(value==='slow' || value==='slowdown') return 'warning';\n if(value==='error' || value==='recovery') return 'danger';\n return 'secondary';\n }\n function profileDiagnosticStatusLabel(status){\n const value=String(status||'unknown').toLowerCase();\n return value==='online' ? 'normal' : value;\n }\n async function refreshProfiles(){ const j=await (await fetch('/api/profiles')).json(); profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p])); const active=String(j.active?.id ?? activeProfileId ?? ''); const rows=j.profiles||[]; const statusMap=new Map(); try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){} $('profileList').innerHTML=rows.map(p=>{ const d=statusMap.get(String(p.id))||{}; const st=profileDiagnosticStatusLabel(d.status || 'unknown'); const cls=profileDiagnosticStatusClass(st); const response=d.response_time_ms?` \u00b7 ${esc(d.response_time_ms)} ms`:''; const threshold=d.slow_threshold_ms?` \u00b7 slow > ${esc(d.slow_threshold_ms)} ms`:''; const isActive=String(p.id)===active; return `${esc(p.name)} active ${p.is_remote?\"remote\":''} ${esc(st)}${esc(p.scgi_url)} \u00b7 heavy ${esc(p.max_parallel_jobs||5)} \u00b7 light ${esc(p.light_parallel_jobs||4)} \u00b7 poll ${esc(p.polling_min_interval_seconds||'-')}s${response}${threshold} `; }).join('')||'No profiles.'; }\n function profileFormPayload(){ return {id:$('profileId')?.value||null,name:$('profileName')?.value||'',scgi_url:$('profileUrl')?.value||'',timeout_seconds:$('profileTimeout')?.value||5,max_parallel_jobs:$('profileParallel')?.value||5,light_parallel_jobs:$('jobLightParallel')?.value||4,light_job_timeout_seconds:$('jobLightTimeout')?.value||300,heavy_job_timeout_seconds:$('jobHeavyTimeout')?.value||7200,pending_job_timeout_seconds:$('jobPendingTimeout')?.value||900,is_remote:$('profileRemote')?.checked}; }\n function renderProfileDiagnostics(d={}){ const box=$('profileDiagnosticsResult'); if(!box) return; const status=profileDiagnosticStatusLabel(d.status || (d.ok?'normal':'error')); const cls=profileDiagnosticStatusClass(status); const paths=d.base_paths||{}; const wp=d.write_permissions||{}; const disk=d.free_disk||{}; const firstDisk=Object.values(disk)[0]||{}; const cards=[['Status',`${esc(status)}`],['rTorrent',esc(d.version||'-')],['Library',esc(d.library_version||'-')],['Response',d.response_time_ms!=null?`${esc(d.response_time_ms)} ms`:'-'],['Slow threshold',d.slow_threshold_ms!=null?`${esc(d.slow_threshold_ms)} ms`:'-'],['Default path',esc(paths.default_directory||'-')],['CWD',esc(paths.cwd||'-')],['Write',esc(Object.values(wp)[0]||'-')],['Free disk',esc(firstDisk.free_h||firstDisk.error||'-')]]; box.classList.remove('text-muted'); box.innerHTML=`${cards.map(([k,v])=>`
${esc(k)}${v}
`).join('')}
${d.error?`${esc(d.error)}
`:''}`; }\n async function testProfilePayload(payload=null){ const p=payload||profileFormPayload(); const res=await post('/api/profiles/test', p); renderProfileDiagnostics(res.diagnostics||{}); return res.diagnostics||{}; }\n\n function resetProfileForm(){ if($('profileId')) $('profileId').value=''; if($('profileName')) $('profileName').value=''; if($('profileUrl')) $('profileUrl').value=''; if($('profileTimeout')) $('profileTimeout').value='5'; if($('profileParallel')) $('profileParallel').value='5'; if($('profileRemote')) $('profileRemote').checked=false; if($('profileFormTitle')) $('profileFormTitle').textContent='Add profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Add profile'; $('cancelProfileEditBtn')?.classList.add('d-none'); }\n function editProfileForm(profile){ if(!profile) return; if($('profileId')) $('profileId').value=profile.id; if($('profileName')) $('profileName').value=profile.name||''; if($('profileUrl')) $('profileUrl').value=profile.scgi_url||''; if($('profileTimeout')) $('profileTimeout').value=profile.timeout_seconds||5; if($('profileParallel')) $('profileParallel').value=profile.max_parallel_jobs||5; if($('profileRemote')) $('profileRemote').checked=!!profile.is_remote; fillJobSettings(profile); if($('profileFormTitle')) $('profileFormTitle').textContent='Edit rTorrent profile'; if($('saveProfileBtn')) $('saveProfileBtn').innerHTML=' Save profile'; $('cancelProfileEditBtn')?.classList.remove('d-none'); $('profileName')?.focus(); }\n async function activateProfileAndRefresh(id, label=''){\n // Note: Profile activation now refreshes all profile-scoped client state without requiring a browser reload.\n if(!id) return;\n setBusy(true, 'Switching profile...');\n try{\n await post(`/api/profiles/${id}/activate`,{});\n activeProfileId=id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(id);\n restoreProfileScopedViewPrefs();\n markActiveProfileRow(id);\n if($('activeProfileName') && label) $('activeProfileName').textContent=label;\n bootstrap.Modal.getInstance($('profilePickerModal'))?.hide();\n defaultDownloadPath=null;\n lastUserDiskFetchAt=0;\n userDiskFetchSeq += 1;\n userDiskFetchInFlight=false;\n clearRtorrentStartingState();\n hasTorrentSnapshot=false;\n torrentSummary=null;\n trackerSummary={hashes:{}, trackers:[], scanned:0, errors:[]};\n trackerSummaryStatus='idle';\n trackerSummarySignature='';\n torrents.clear();\n selected.clear();\n selectedHash=null;\n scheduleRender(true);\n await loadPreferences().catch(()=>{});\n await Promise.allSettled([\n refreshProfiles(),\n applyDefaultDownloadPath(true),\n refreshUserDiskUsage(true),\n loadSmartQueue(),\n loadDownloadPlanner(),\n loadPollerSettings(),\n ]);\n socket.emit('select_profile',{profile_id:Number(id)});\n toast('Profile switched','success');\n }catch(e){\n toast(e.message||'Profile switch failed','danger');\n }finally{\n setBusy(false);\n }\n }\n\n // Note: The rTorrent list lives in Tools modal; refresh it when that modal is shown instead of referencing a missing modal id.\n $('profilePickerModal')?.addEventListener('show.bs.modal',async()=>{\n try{\n const j=await (await fetch('/api/profiles')).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n }); $('profileList')?.addEventListener('click',async e=>{const btn=e.target.closest('[data-del-profile],[data-use-profile],[data-edit-profile],[data-test-saved-profile]'); const del=btn?.dataset.delProfile,use=btn?.dataset.useProfile,edit=btn?.dataset.editProfile,test=btn?.dataset.testSavedProfile;if(test){ const oldHtml=btn.innerHTML; btn.disabled=true; btn.innerHTML=' testing'; const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='Testing saved profile...
'; try{ const r=await (await fetch(`/api/profiles/${test}/diagnostics`)).json(); renderProfileDiagnostics(r.diagnostics||{}); }catch(e){ if(box) box.innerHTML=` ${esc(e.message)}
`; toast(e.message,'danger'); } finally{ btn.disabled=false; btn.innerHTML=oldHtml; } return; } if(edit){editProfileForm(profileCache.get(String(edit)));return;} if(del){setBusy(true);await fetch(`/api/profiles/${del}`,{method:'DELETE'});setBusy(false);refreshProfiles();location.reload();} if(use){await activateProfileAndRefresh(use, profileCache.get(String(use))?.name || 'rTorrent');}}); $('cancelProfileEditBtn')?.addEventListener('click',resetProfileForm); $('testProfileBtn')?.addEventListener('click',async()=>{ const btn=$('testProfileBtn'); const oldHtml=btn?.innerHTML; if(btn){ btn.disabled=true; btn.innerHTML=' Testing SCGI...'; } const box=$('profileDiagnosticsResult'); if(box) box.innerHTML='Testing SCGI connection...
'; setBusy(true); try{ const d=await testProfilePayload(); toast(d.ok?'SCGI test OK':'SCGI test failed', d.ok?'success':'danger'); }catch(e){ toast(e.message,'danger'); if(box) box.innerHTML=` ${esc(e.message)}
`; } finally{setBusy(false); if(btn){ btn.disabled=false; btn.innerHTML=oldHtml||' Test SCGI'; }} }); $('profileExportBtn')?.addEventListener('click',async()=>{ const j=await (await fetch('/api/profiles/export')).json(); const blob=new Blob([JSON.stringify(j,null,2)],{type:'application/json'}); const a=document.createElement('a'); a.href=URL.createObjectURL(blob); a.download='pytorrent-profiles.json'; a.click(); setTimeout(()=>URL.revokeObjectURL(a.href),1000); }); $('profileImportBtn')?.addEventListener('click',()=>$('profileImportFile')?.click()); $('profileImportFile')?.addEventListener('change',async e=>{ const file=e.target.files?.[0]; if(!file) return; try{ const payload=JSON.parse(await file.text()); await post('/api/profiles/import',payload); toast('Profiles imported','success'); refreshProfiles(); }catch(err){ toast(err.message,'danger'); } e.target.value=''; }); $('saveProfileBtn')?.addEventListener('click',async()=>{setBusy(true);const id=$('profileId')?.value;const payload=profileFormPayload();const j=await post(id?`/api/profiles/${id}`:'/api/profiles',payload,id?'PUT':'POST').catch(e=>toast(e.message,'danger'));setBusy(false);if(j?.profile)location.reload();}); $('saveJobSettingsBtn')?.addEventListener('click',saveJobSettings); $('reloadJobSettingsBtn')?.addEventListener('click',loadJobSettings); $('profileSelect')?.addEventListener('change',async e=>{const id=e.target.value;if(!id)return;const opt=e.target.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');}); $('profilePickerUseBtn')?.addEventListener('click',async()=>{const select=$('profileSelect');const id=select?.value;if(!id)return;const opt=select.selectedOptions?.[0];await activateProfileAndRefresh(id, opt?.textContent || 'rTorrent');});\n // Note: Opens the existing rTorrent form directly from the empty first-run state.\n document.addEventListener('click',e=>{ if(e.target.closest('#setupProfileBtn')){ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); setTimeout(()=>$('profileName')?.focus(),150); return; } if(e.target.closest('#chooseProfileBtn')){ openProfilePicker(); } });\n function renderProfileSelectionState(count=0){\n hasTorrentSnapshot = false;\n torrentSummary = {filters:{all:{count:0},downloading:{count:0},queued:{count:0},seeding:{count:0},paused:{count:0},checking:{count:0},error:{count:0},stopped:{count:0}}};\n torrents.clear();\n selected.clear();\n renderCounts();\n const body = $('torrentBody');\n if(body){\n body.innerHTML = `Select an rTorrent profile.${esc(count)} profile(s) are configured for this trusted bypass session. Choose which one to open. |
`;\n }\n const list = $('mobileList');\n if(list) list.innerHTML = `Select an rTorrent profile.Choose a profile to load torrents.
`;\n if($('detailPane')) $('detailPane').innerHTML = 'Choose an rTorrent profile to load details.';\n }\n\n async function openProfilePicker(){\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n const select=$('profileSelect');\n if(select) select.innerHTML=(j.profiles||[]).map(p=>``).join('') || '';\n }catch(e){}\n new bootstrap.Modal($('profilePickerModal')).show();\n }\n\n // Note: On trusted auth-bypass entry, existing profiles are not auto-selected; the visitor must choose the target profile.\n async function showFirstRunSetup(){\n if(hasActiveProfile || firstRunSetupShown) return;\n firstRunSetupShown = true;\n let profiles=[];\n try{\n const j=await (await fetch('/api/profiles',{cache:'no-store'})).json();\n if(j.active?.id){\n activeProfileId=j.active.id;\n hasActiveProfile=true;\n window.PYTORRENT.activeProfile=Number(j.active.id);\n return;\n }\n profiles=j.profiles||[];\n }catch(e){}\n $('connBadge').className='badge text-bg-warning';\n if(profiles.length){\n $('connBadge').textContent='select profile';\n setInitialLoader('Select rTorrent profile','Choose which configured rTorrent profile to open.');\n renderProfileSelectionState(profiles.length);\n hideInitialLoader();\n setTimeout(()=>openProfilePicker(), 120);\n return;\n }\n $('connBadge').textContent='setup required';\n setInitialLoader('Configure rTorrent','Add the first rTorrent profile to start loading torrents.');\n renderNoProfileState();\n hideInitialLoader();\n setTimeout(()=>{ activateToolTab('rtorrents'); new bootstrap.Modal($('toolsModal')).show(); }, 120);\n }\n";
diff --git a/pytorrent/static/js/stateCore.js b/pytorrent/static/js/stateCore.js
index fde2a47..cf9de26 100644
--- a/pytorrent/static/js/stateCore.js
+++ b/pytorrent/static/js/stateCore.js
@@ -1 +1 @@
-export const stateCoreSource = " const $ = (id) => document.getElementById(id);\n const esc = (s) => String(s ?? \"\").replace(/[&<>'\"]/g, c => ({\"&\":\"&\",\"<\":\"<\",\">\":\">\",\"'\":\"'\",'\"':\""\"}[c]));\n // Note: Footer transfer totals can arrive as already formatted strings, so keep this helper tolerant and side-effect free.\n function compactTransferText(value){\n const text = String(value ?? \"\").trim();\n if(!text) return \"-\";\n return text.replace(/\\\\s+/g, \" \");\n }\n function clampTorrentListFontSize(value){ value = Number(value || 13); if(!Number.isFinite(value)) value = 13; return Math.max(11, Math.min(16, Math.round(value))); }\n const ROW_HEIGHT = 32, COMPACT_ROW_HEIGHT = 24, OVERSCAN = 14;\n const torrents = new Map();\n function browserViewPrefsKey(){\n const profileId = String(window.PYTORRENT?.activeProfile || activeProfileId || 'none');\n return `pyTorrent.mobileViewPrefs.${profileId}`;\n }\n function loadBrowserViewPrefs(){\n try{\n const scoped = JSON.parse(localStorage.getItem(browserViewPrefsKey())||'{}')||{};\n if(Object.keys(scoped).length) return scoped;\n const legacy = JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};\n return String(window.PYTORRENT?.activeProfile || activeProfileId || 'none') === 'none' ? legacy : {};\n }catch(e){ return {}; }\n }\n function isActiveProfilePayload(payload){\n const payloadProfile = Number(payload?.profile_id || 0);\n const activeProfile = Number(window.PYTORRENT?.activeProfile || activeProfileId || 0);\n return !payloadProfile || !activeProfile || payloadProfile === activeProfile;\n }\n const browserViewPrefs = loadBrowserViewPrefs();\n const savedFilter = String(browserViewPrefs.activeFilter || window.PYTORRENT?.activeFilter || \"all\");\n let sidebarLabelsExpanded = Number(window.PYTORRENT?.sidebarLabelsExpanded || 0) !== 0;\n let sidebarShortcutsExpanded = Number(window.PYTORRENT?.sidebarShortcutsExpanded || 0) !== 0;\n const SMART_QUEUE_TECHNICAL_LABEL = String(window.PYTORRENT?.smartQueueTechnicalLabel || \"Smart Queue Stopped\").trim();\n const SMART_QUEUE_STALLED_LABEL = String(window.PYTORRENT?.smartQueueStalledLabel || \"Stalled\").trim();\n // Note: Mobile has both \"All\" and \"All trackers\" options, so keep the exact selected option separate from the shared filter state.\n let mobileActiveFilterKey = String(browserViewPrefs.mobileFilterKey || savedFilter || \"all\");\n let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = savedFilter.startsWith(\"tracker:\") ? \"all\" : (savedFilter || \"all\");\n let activeTrackerFilter = savedFilter.startsWith(\"tracker:\") ? savedFilter.slice(8) : \"\";\n const SORT_KEYS = new Set([\"name\", \"status\", \"size\", \"progress\", \"down_rate\", \"up_rate\", \"eta\", \"seeds\", \"peers\", \"ratio\", \"path\", \"label\", \"ratio_group\", \"down_total\", \"to_download\", \"up_total\", \"created\", \"last_activity\", \"priority\", \"state\", \"active\", \"complete\", \"hashing\", \"message\", \"hash\"]);\n const savedSort = browserViewPrefs.sortState || window.PYTORRENT?.torrentSort || {};\n let sortState = {key: SORT_KEYS.has(savedSort.key) ? savedSort.key : \"name\", dir: Number(savedSort.dir) < 0 ? -1 : 1}, renderPending = false, renderVersion = 0, lastRenderSignature = \"\";\n let compactTorrentListEnabled = Number(window.PYTORRENT?.compactTorrentListEnabled || 0) !== 0;\n let torrentListFontSize = clampTorrentListFontSize(window.PYTORRENT?.torrentListFontSize || 13);\n // Note: Mobile sort filters are configurable because the full sortable list is too large for quick phone use.\n const DEFAULT_MOBILE_SORT_FILTER_IDS = new Set([\"seeds:-1\", \"up_rate:-1\", \"down_rate:-1\", \"progress:-1\"]);\n const MOBILE_SORT_STEPS = [\n {key:\"down_rate\", dir:-1, label:\"DL\"},\n {key:\"down_rate\", dir:1, label:\"DL\"},\n {key:\"up_rate\", dir:-1, label:\"UL\"},\n {key:\"up_rate\", dir:1, label:\"UL\"},\n {key:\"progress\", dir:-1, label:\"Progress\"},\n {key:\"progress\", dir:1, label:\"Progress\"},\n {key:\"eta\", dir:-1, label:\"ETA\"},\n {key:\"eta\", dir:1, label:\"ETA\"},\n {key:\"ratio\", dir:-1, label:\"Ratio\"},\n {key:\"ratio\", dir:1, label:\"Ratio\"},\n {key:\"size\", dir:-1, label:\"Size\"},\n {key:\"size\", dir:1, label:\"Size\"},\n {key:\"seeds\", dir:-1, label:\"Seeds\"},\n {key:\"seeds\", dir:1, label:\"Seeds\"},\n {key:\"peers\", dir:-1, label:\"Peers\"},\n {key:\"peers\", dir:1, label:\"Peers\"},\n {key:\"status\", dir:1, label:\"Status\"},\n {key:\"status\", dir:-1, label:\"Status\"},\n {key:\"label\", dir:1, label:\"Label\"},\n {key:\"label\", dir:-1, label:\"Label\"},\n {key:\"ratio_group\", dir:1, label:\"Ratio group\"},\n {key:\"ratio_group\", dir:-1, label:\"Ratio group\"},\n {key:\"down_total\", dir:-1, label:\"Downloaded\"},\n {key:\"down_total\", dir:1, label:\"Downloaded\"},\n {key:\"to_download\", dir:-1, label:\"To download\"},\n {key:\"to_download\", dir:1, label:\"To download\"},\n {key:\"up_total\", dir:-1, label:\"Uploaded\"},\n {key:\"up_total\", dir:1, label:\"Uploaded\"},\n {key:\"created\", dir:-1, label:\"Created\"},\n {key:\"created\", dir:1, label:\"Created\"},\n {key:\"last_activity\", dir:-1, label:\"Last activity\"},\n {key:\"last_activity\", dir:1, label:\"Last activity\"},\n {key:\"priority\", dir:-1, label:\"Priority\"},\n {key:\"priority\", dir:1, label:\"Priority\"},\n {key:\"state\", dir:-1, label:\"State\"},\n {key:\"state\", dir:1, label:\"State\"},\n {key:\"active\", dir:-1, label:\"Active\"},\n {key:\"active\", dir:1, label:\"Active\"},\n {key:\"complete\", dir:-1, label:\"Complete\"},\n {key:\"complete\", dir:1, label:\"Complete\"},\n {key:\"hashing\", dir:-1, label:\"Hashing\"},\n {key:\"hashing\", dir:1, label:\"Hashing\"},\n {key:\"message\", dir:1, label:\"Message\"},\n {key:\"message\", dir:-1, label:\"Message\"},\n {key:\"path\", dir:1, label:\"Path\"},\n {key:\"path\", dir:-1, label:\"Path\"},\n {key:\"hash\", dir:1, label:\"Hash\"},\n {key:\"hash\", dir:-1, label:\"Hash\"},\n {key:\"name\", dir:1, label:\"Name\"},\n {key:\"name\", dir:-1, label:\"Name\"}\n ];\n let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = \"/\";\n const traffic = [], systemUsage = [];\n const socket = (typeof io === \"function\") ? io({transports:[\"polling\"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000}) : {connected:false,on(){},emit(){},io:{on(){}}};\n";
+export const stateCoreSource = " const $ = (id) => document.getElementById(id);\n const esc = (s) => String(s ?? \"\").replace(/[&<>'\"]/g, c => ({\"&\":\"&\",\"<\":\"<\",\">\":\">\",\"'\":\"'\",'\"':\""\"}[c]));\n // Note: Footer transfer totals can arrive as already formatted strings, so keep this helper tolerant and side-effect free.\n function compactTransferText(value){\n const text = String(value ?? \"\").trim();\n if(!text) return \"-\";\n return text.replace(/\\\\s+/g, \" \");\n }\n function clampTorrentListFontSize(value){ value = Number(value || 13); if(!Number.isFinite(value)) value = 13; return Math.max(11, Math.min(16, Math.round(value))); }\n const ROW_HEIGHT = 32, COMPACT_ROW_HEIGHT = 24, OVERSCAN = 14;\n const torrents = new Map();\n function browserViewPrefsKey(){\n const profileId = String(window.PYTORRENT?.activeProfile || activeProfileId || 'none');\n return `pyTorrent.mobileViewPrefs.${profileId}`;\n }\n function loadBrowserViewPrefs(){\n try{\n const scoped = JSON.parse(localStorage.getItem(browserViewPrefsKey())||'{}')||{};\n if(Object.keys(scoped).length) return scoped;\n const legacy = JSON.parse(localStorage.getItem('pyTorrent.mobileViewPrefs')||'{}')||{};\n return String(window.PYTORRENT?.activeProfile || activeProfileId || 'none') === 'none' ? legacy : {};\n }catch(e){ return {}; }\n }\n function isActiveProfilePayload(payload){\n const payloadProfile = Number(payload?.profile_id || 0);\n const activeProfile = Number(window.PYTORRENT?.activeProfile || activeProfileId || 0);\n return !payloadProfile || !activeProfile || payloadProfile === activeProfile;\n }\n let browserViewPrefs = loadBrowserViewPrefs();\n function restoreProfileScopedViewPrefs(){\n // Note: Profile switch must reload browser-only filters/sort from the active profile key; otherwise stale values stay until hard refresh.\n browserViewPrefs = loadBrowserViewPrefs();\n const restoredFilter = String(browserViewPrefs.activeFilter || window.PYTORRENT?.activeFilter || \"all\");\n mobileActiveFilterKey = String(browserViewPrefs.mobileFilterKey || restoredFilter || \"all\");\n activeFilter = restoredFilter.startsWith(\"tracker:\") ? \"all\" : (restoredFilter || \"all\");\n activeTrackerFilter = restoredFilter.startsWith(\"tracker:\") ? restoredFilter.slice(8) : \"\";\n const restoredSort = browserViewPrefs.sortState || window.PYTORRENT?.torrentSort || {};\n sortState = {key: SORT_KEYS.has(restoredSort.key) ? restoredSort.key : \"name\", dir: Number(restoredSort.dir) < 0 ? -1 : 1};\n if(browserViewPrefs.columnWidths) columnWidths = normalizeColumnWidths({...columnWidths, ...browserViewPrefs.columnWidths});\n if(browserViewPrefs.mobileColumns) mobileColumns = normalizeMobileColumns({...mobileColumns, ...browserViewPrefs.mobileColumns});\n if(browserViewPrefs.mobileSortFilters) mobileSortFilters = normalizeMobileSortFilters({...mobileSortFilters, ...browserViewPrefs.mobileSortFilters});\n if($('searchBox')) $('searchBox').value = '';\n if($('tableWrap')) $('tableWrap').scrollTop = 0;\n if($('mobileList')) $('mobileList').scrollTop = 0;\n lastRenderSignature = '';\n lastLabelFiltersSignature = '';\n lastTrackerFiltersSignature = '';\n }\n const savedFilter = String(browserViewPrefs.activeFilter || window.PYTORRENT?.activeFilter || \"all\");\n let sidebarLabelsExpanded = Number(window.PYTORRENT?.sidebarLabelsExpanded || 0) !== 0;\n let sidebarShortcutsExpanded = Number(window.PYTORRENT?.sidebarShortcutsExpanded || 0) !== 0;\n const SMART_QUEUE_TECHNICAL_LABEL = String(window.PYTORRENT?.smartQueueTechnicalLabel || \"Smart Queue Stopped\").trim();\n const SMART_QUEUE_STALLED_LABEL = String(window.PYTORRENT?.smartQueueStalledLabel || \"Stalled\").trim();\n // Note: Mobile has both \"All\" and \"All trackers\" options, so keep the exact selected option separate from the shared filter state.\n let mobileActiveFilterKey = String(browserViewPrefs.mobileFilterKey || savedFilter || \"all\");\n let visibleRows = [], selected = new Set(), selectedHash = null, lastSelectedHash = null, activeFilter = savedFilter.startsWith(\"tracker:\") ? \"all\" : (savedFilter || \"all\");\n let activeTrackerFilter = savedFilter.startsWith(\"tracker:\") ? savedFilter.slice(8) : \"\";\n const SORT_KEYS = new Set([\"name\", \"status\", \"size\", \"progress\", \"down_rate\", \"up_rate\", \"eta\", \"seeds\", \"peers\", \"ratio\", \"path\", \"label\", \"ratio_group\", \"down_total\", \"to_download\", \"up_total\", \"created\", \"last_activity\", \"priority\", \"state\", \"active\", \"complete\", \"hashing\", \"message\", \"hash\"]);\n const savedSort = browserViewPrefs.sortState || window.PYTORRENT?.torrentSort || {};\n let sortState = {key: SORT_KEYS.has(savedSort.key) ? savedSort.key : \"name\", dir: Number(savedSort.dir) < 0 ? -1 : 1}, renderPending = false, renderVersion = 0, lastRenderSignature = \"\";\n let compactTorrentListEnabled = Number(window.PYTORRENT?.compactTorrentListEnabled || 0) !== 0;\n let torrentListFontSize = clampTorrentListFontSize(window.PYTORRENT?.torrentListFontSize || 13);\n // Note: Mobile sort filters are configurable because the full sortable list is too large for quick phone use.\n const DEFAULT_MOBILE_SORT_FILTER_IDS = new Set([\"seeds:-1\", \"up_rate:-1\", \"down_rate:-1\", \"progress:-1\"]);\n const MOBILE_SORT_STEPS = [\n {key:\"down_rate\", dir:-1, label:\"DL\"},\n {key:\"down_rate\", dir:1, label:\"DL\"},\n {key:\"up_rate\", dir:-1, label:\"UL\"},\n {key:\"up_rate\", dir:1, label:\"UL\"},\n {key:\"progress\", dir:-1, label:\"Progress\"},\n {key:\"progress\", dir:1, label:\"Progress\"},\n {key:\"eta\", dir:-1, label:\"ETA\"},\n {key:\"eta\", dir:1, label:\"ETA\"},\n {key:\"ratio\", dir:-1, label:\"Ratio\"},\n {key:\"ratio\", dir:1, label:\"Ratio\"},\n {key:\"size\", dir:-1, label:\"Size\"},\n {key:\"size\", dir:1, label:\"Size\"},\n {key:\"seeds\", dir:-1, label:\"Seeds\"},\n {key:\"seeds\", dir:1, label:\"Seeds\"},\n {key:\"peers\", dir:-1, label:\"Peers\"},\n {key:\"peers\", dir:1, label:\"Peers\"},\n {key:\"status\", dir:1, label:\"Status\"},\n {key:\"status\", dir:-1, label:\"Status\"},\n {key:\"label\", dir:1, label:\"Label\"},\n {key:\"label\", dir:-1, label:\"Label\"},\n {key:\"ratio_group\", dir:1, label:\"Ratio group\"},\n {key:\"ratio_group\", dir:-1, label:\"Ratio group\"},\n {key:\"down_total\", dir:-1, label:\"Downloaded\"},\n {key:\"down_total\", dir:1, label:\"Downloaded\"},\n {key:\"to_download\", dir:-1, label:\"To download\"},\n {key:\"to_download\", dir:1, label:\"To download\"},\n {key:\"up_total\", dir:-1, label:\"Uploaded\"},\n {key:\"up_total\", dir:1, label:\"Uploaded\"},\n {key:\"created\", dir:-1, label:\"Created\"},\n {key:\"created\", dir:1, label:\"Created\"},\n {key:\"last_activity\", dir:-1, label:\"Last activity\"},\n {key:\"last_activity\", dir:1, label:\"Last activity\"},\n {key:\"priority\", dir:-1, label:\"Priority\"},\n {key:\"priority\", dir:1, label:\"Priority\"},\n {key:\"state\", dir:-1, label:\"State\"},\n {key:\"state\", dir:1, label:\"State\"},\n {key:\"active\", dir:-1, label:\"Active\"},\n {key:\"active\", dir:1, label:\"Active\"},\n {key:\"complete\", dir:-1, label:\"Complete\"},\n {key:\"complete\", dir:1, label:\"Complete\"},\n {key:\"hashing\", dir:-1, label:\"Hashing\"},\n {key:\"hashing\", dir:1, label:\"Hashing\"},\n {key:\"message\", dir:1, label:\"Message\"},\n {key:\"message\", dir:-1, label:\"Message\"},\n {key:\"path\", dir:1, label:\"Path\"},\n {key:\"path\", dir:-1, label:\"Path\"},\n {key:\"hash\", dir:1, label:\"Hash\"},\n {key:\"hash\", dir:-1, label:\"Hash\"},\n {key:\"name\", dir:1, label:\"Name\"},\n {key:\"name\", dir:-1, label:\"Name\"}\n ];\n let lastLimits = {down: 0, up: 0}, pendingBusy = 0, pathTarget = null, lastPathParent = \"/\";\n const traffic = [], systemUsage = [];\n const socket = (typeof io === \"function\") ? io({transports:[\"polling\"], reconnection:true, reconnectionAttempts:Infinity, reconnectionDelay:700, reconnectionDelayMax:5000, timeout:8000}) : {connected:false,on(){},emit(){},io:{on(){}}};\n";