From d5fa689dad7f3cc5ca957cf0656f45d76c182c0c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Tue, 16 Jun 2026 22:28:39 +0200 Subject: [PATCH] fix in u --- pytorrent/routes/planner.py | 3 ++- pytorrent/services/poller_control.py | 13 ++++++++++++- pytorrent/static/js/appStatus.js | 2 +- pytorrent/static/js/planner.js | 2 +- pytorrent/static/js/pollerSettings.js | 2 +- pytorrent/static/js/profileList.js | 2 +- pytorrent/static/js/profiles.js | 2 +- pytorrent/static/js/toolsModal.js | 2 +- pytorrent/static/styles.css | 10 ++++++++++ 9 files changed, 30 insertions(+), 8 deletions(-) diff --git a/pytorrent/routes/planner.py b/pytorrent/routes/planner.py index d113ff9..e2412a3 100644 --- a/pytorrent/routes/planner.py +++ b/pytorrent/routes/planner.py @@ -95,7 +95,8 @@ def poller_settings_get(): if error: return error pid = int(profile["id"]) - return ok({"settings": poller_control.get_settings(pid), "runtime": poller_control.snapshot(pid)}) + settings = poller_control.get_settings(pid) + return ok({"settings": settings, "runtime": poller_control.snapshot(pid, settings)}) @bp.post("/poller/settings") diff --git a/pytorrent/services/poller_control.py b/pytorrent/services/poller_control.py index 32e386a..5a0067e 100644 --- a/pytorrent/services/poller_control.py +++ b/pytorrent/services/poller_control.py @@ -385,9 +385,20 @@ def mark_tick(state: ProfilePollState, started_at: float, active: bool, ok: bool return dict(state.stats) -def snapshot(profile_id: int) -> dict: +def snapshot(profile_id: int, settings: dict | None = None) -> dict: state = state_for(profile_id) + effective_settings = normalize_settings(settings) if settings is not None else get_settings(profile_id) data = dict(state.stats or {"profile_id": int(profile_id), "tick_count": state.tick_count}) + runtime_ready = bool(state.stats) or state.tick_count > 0 + # Note: Snapshot includes saved intervals even before the first runtime tick so diagnostics never render as an empty zero-only panel. + data.setdefault("runtime_ready", runtime_ready) + data.setdefault("adaptive_enabled", bool(effective_settings.get("adaptive_enabled", DEFAULTS["adaptive_enabled"]))) + data.setdefault("adaptive_mode", state.adaptive_mode if runtime_ready else ("fixed" if not data.get("adaptive_enabled") else "waiting")) + data.setdefault("live_stats_interval_seconds", effective_live_interval(effective_settings, state)) + data.setdefault("torrent_list_interval_seconds", effective_list_interval(effective_settings, state)) + data.setdefault("configured_min_interval_seconds", MIN_POLL_INTERVAL_SECONDS) + if not runtime_ready: + data["last_ok"] = None # Note: Snapshot always exposes split-poller counters, even before the first post-cleanup tick rebuilds full stats. data.update({ "live_poll_count": state.live_poll_count, diff --git a/pytorrent/static/js/appStatus.js b/pytorrent/static/js/appStatus.js index bf760a8..96a5649 100644 --- a/pytorrent/static/js/appStatus.js +++ b/pytorrent/static/js/appStatus.js @@ -1 +1 @@ -export const appStatusSource = " async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{};\n const rt=poller.runtime||{}, ps=poller.settings||{};\n // Note: App status now keeps only unique operational diagnostics; storage, jobs, planner and queue details stay in their dedicated tools.\n const processCards=[\n diagCard('PID', py.pid),\n diagCard('Uptime', `${py.uptime_seconds||0}s`),\n diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),\n diagCard('Threads', py.threads),\n diagCard('CPU', `${py.cpu_percent ?? '-'}%`),\n diagCard('Python', py.python||'-'),\n diagCard('Worker threads', py.worker_threads ?? '-'),\n diagCard('Jobs total', py.jobs_total ?? '-')\n ];\n const pollerCards=[\n diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'),\n diagCard('Mode', rt.adaptive_mode||'-'),\n diagCard('Live interval', `${rt.live_stats_interval_seconds ?? ps.live_stats_interval_seconds ?? '-'}s`),\n diagCard('List interval', `${rt.torrent_list_interval_seconds ?? ps.torrent_list_interval_seconds ?? '-'}s`),\n diagCard('Last tick', `${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', rt.rtorrent_call_count||0)\n ];\n const connectionCards=[\n diagCard('Active profile', profile.name||profile.id||'-'),\n diagCard('API response time', `${st.api_ms ?? '-'} ms`),\n diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'),\n diagCard('SCGI URL', scgi.url||'-'),\n diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),\n diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'),\n diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'),\n diagCard('Request bytes', scgi.request_bytes),\n diagCard('Response bytes', scgi.response_bytes),\n diagCard('XML bytes', scgi.xml_bytes),\n diagCard('rTorrent version', scgi.client_version||'-')\n ];\n const panes=[\n ['process','Process', `${diagnosticsSection('pyTorrent process', processCards)}${diagnosticsSection('Runtime poller', pollerCards)}`],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', connectionCards)]\n ];\n const tabs=`
`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`
${p[2]}
`).join('')}${scgi.error?`
${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';"; +export const appStatusSource = " async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML=' Loading diagnostics...';\n try{\n const [status,poller]=await Promise.all([\n fetch('/api/app/status').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({}))\n ]);\n if(!status.ok) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, profile=st.profile||{};\n const rt=poller.runtime||{}, ps=poller.settings||{};\n // Note: App status now keeps only unique operational diagnostics; storage, jobs, planner and queue details stay in their dedicated tools.\n const processCards=[\n diagCard('PID', py.pid),\n diagCard('Uptime', `${py.uptime_seconds||0}s`),\n diagCard('Memory RSS', py.memory_rss_h||py.memory_rss),\n diagCard('Threads', py.threads),\n diagCard('CPU', `${py.cpu_percent ?? '-'}%`),\n diagCard('Python', py.python||'-'),\n diagCard('Worker threads', py.worker_threads ?? '-'),\n diagCard('Jobs total', py.jobs_total ?? '-')\n ];\n const runtimeReady=rt.runtime_ready!==false && (Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0);\n const pollerPending=runtimeReady?'':'waiting';\n const pollerCards=[\n diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'),\n diagCard('Mode', pollerPending || rt.adaptive_mode || ((rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'fixed':'normal')),\n diagCard('Live interval', `${rt.live_stats_interval_seconds ?? ps.live_stats_interval_seconds ?? '-'}s`),\n diagCard('List interval', `${rt.torrent_list_interval_seconds ?? ps.torrent_list_interval_seconds ?? '-'}s`),\n diagCard('Last tick', pollerPending || `${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', pollerPending || `${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', pollerPending || fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', pollerPending || (rt.rtorrent_call_count||0))\n ];\n const connectionCards=[\n diagCard('Active profile', profile.name||profile.id||'-'),\n diagCard('API response time', `${st.api_ms ?? '-'} ms`),\n diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'),\n diagCard('SCGI URL', scgi.url||'-'),\n diagCard('SCGI connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'),\n diagCard('SCGI first byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'),\n diagCard('SCGI total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'),\n diagCard('Request bytes', scgi.request_bytes),\n diagCard('Response bytes', scgi.response_bytes),\n diagCard('XML bytes', scgi.xml_bytes),\n diagCard('rTorrent version', scgi.client_version||'-')\n ];\n const panes=[\n ['process','Process', `${diagnosticsSection('pyTorrent process', processCards)}${diagnosticsSection('Runtime poller', pollerCards)}`],\n ['connection','Connection', diagnosticsSection('Profile and rTorrent', connectionCards)]\n ];\n const tabs=`
`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`
${p[2]}
`).join('')}${scgi.error?`
${esc(scgi.error)}
`:''}`;\n }catch(e){ box.innerHTML=`
${esc(e.message)}
`; }\n }\n\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';"; diff --git a/pytorrent/static/js/planner.js b/pytorrent/static/js/planner.js index ae348f6..6ec8aac 100644 --- a/pytorrent/static/js/planner.js +++ b/pytorrent/static/js/planner.js @@ -1 +1 @@ -export const plannerSource = " function ensurePlannerToolsUI(){\n addToolTab('planner','fa-calendar-days','Planner','appstatus');\n addToolTab('poller','fa-satellite-dish','Poller','appstatus');\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n if(!$('toolPlanner')){\n const panel=document.createElement('div');\n panel.id='toolPlanner'; panel.className='d-none';\n panel.innerHTML=`
\n \n
\n
\n
\n
\n
\n
Download planner off
\n
${inlineSwitch('plannerEnabled')}
\n
\n
Current settingsLoading planner settings...
\n
\n
\n Basics\n
\n \n \n \n \n
\n
\n
\n Hourly speed planner\n ${plannerToggleRow('plannerHourlyEnabled','Use hourly speed limits','When enabled, the current hour overrides weekday and weekend speed limits.')}\n
\n
\n
\n
\n Fallback speed limits\n
${plannerSpeedCard('plannerWeekday','Weekday limits','Used when hourly planner is disabled')}${plannerSpeedCard('plannerWeekend','Weekend limits','Saturday and Sunday fallback')}
\n
\n
\n Time windows\n
\n ${plannerToggleRow('plannerNightOnly','Download only at night','Pause downloads outside the selected window.')}\n ${plannerToggleRow('plannerQuietEnabled','Quiet hours','Pause active downloads during the selected quiet window.')}\n
\n
\n \n \n \n \n
\n
\n
\n Protection\n
\n ${plannerToggleRow('plannerCpuEnabled','CPU protection','Pause downloads when CPU usage stays above the threshold for about 10 seconds.')}\n ${plannerToggleRow('plannerDiskEnabled','Disk protection','Pause downloads and block new download starts when disk usage is high.')}\n ${plannerToggleRow('plannerNetworkEnabled','Network protection','Clamp Planner speed limits to configured network caps.')}\n ${plannerToggleRow('plannerLoadEnabled','Load protection','Pause downloads when system load is above threshold.')}\n ${plannerToggleRow('plannerAutoResume','Auto resume planner-paused torrents','Resume only torrents paused by the planner when all protection rules become clear.')}\n
\n
\n \n \n \n \n \n
\n
\n
PreviewNo preview loaded.
\n
\n
\n
\n
\n
\n
\n
Action history
No actions yet.
\n
\n
\n
`\n host.appendChild(panel);\n renderPlannerHourlyGrid();\n // Note: Planner cards are collapsed by default; the summary bar keeps the active state visible.\n panel.addEventListener('change', e=>{ if(e.target.closest('#toolPlanner')) updatePlannerCurrentSummary(); });\n $('plannerSaveBtn')?.addEventListener('click',saveDownloadPlanner);\n $('plannerCheckBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(false));\n $('plannerDryRunBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(true));\n $('plannerOverrideBtn')?.addEventListener('click',setPlannerOverride);\n $('plannerPreviewBtn')?.addEventListener('click',loadPlannerPreview);\n $('plannerHistory')?.addEventListener('click',async e=>{\n const toggle=e.target.closest('#plannerHistoryToggle');\n const clear=e.target.closest('#plannerHistoryClear');\n if(toggle){ plannerHistoryExpanded=!plannerHistoryExpanded; await loadPlannerPreview(); return; }\n if(clear && confirm('Clear Planner action history?')){\n try{ await post('/api/download-planner/history',{},'DELETE'); plannerHistoryExpanded=false; await loadPlannerPreview(); toast('Planner history cleared','success'); }\n catch(err){ toast(err.message,'danger'); }\n }\n });\n $('plannerProfileName')?.addEventListener('change',applyPlannerPreset);\n $('plannerHourCopyWeekday')?.addEventListener('click',()=>copyPlannerSpeedToHours('plannerWeekday'));\n document.querySelectorAll('.planner-hour-fill').forEach(btn=>btn.addEventListener('click',()=>fillPlannerHours(Number(btn.dataset.mbps||0))));\n setupPlannerSpeedControls();\n }\n if(!$('toolPoller')){\n const panel=document.createElement('div');\n panel.id='toolPoller'; panel.className='d-none';\n panel.innerHTML=`
\n
\n
Smart poller normal
Controls separate lightweight live polling and slower full torrent-list polling per active rTorrent profile.
\n
${inlineSwitch('pollerAdaptive')}
\n
\n
\n
\n Adaptive behavior\n
These values decide when the poller slows down, recovers after errors, or switches between active and idle refreshes.
\n
\n \n \n \n \n \n \n
\n
\n
\n Live poller\n
Fast lightweight loop for volatile data: speeds, torrent status changes and UI heartbeat. It should stay responsive, but not below the safe baseline on busy rTorrent instances.
\n
\n \n \n \n
\n
\n
\n Full poller\n
Slower loop for heavier work: full torrent snapshot/diff, tracker summary, disk state, queue, jobs and planner checks.
\n
\n \n \n \n \n
\n
\n ${plannerToggleRow('pollerSafeFallback','Safe fallback mode','When enabled, unsafe intervals are raised before saving. It protects rTorrent and the browser from too-aggressive refresh loops while keeping values that are already safe unchanged.')}\n
Enable Safe fallback mode to protect the app from too-aggressive poller intervals.
\n
DiagnosticsNot loaded.
\n
\n
\n
`;\n host.appendChild(panel);\n $('pollerSaveBtn')?.addEventListener('click',savePollerSettings);\n $('pollerReloadBtn')?.addEventListener('click',loadPollerSettings);\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('input',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('change',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview();\n }\n }\n const plannerMbpsToBytes=mbps=>mbps?Math.round(Number(mbps)*1000000/8):0;\n const plannerBytesToMbps=bytes=>bytes?Math.round(Number(bytes)*8/1000000):0;\n function plannerLimitText(bytes){ const mbps=plannerBytesToMbps(Number(bytes||0)); return mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function plannerHourLabel(hour){ return `${String(hour).padStart(2,'0')}:00-${String((hour+1)%24).padStart(2,'0')}:00`; }\n function renderPlannerHourlyGrid(){\n const box=$('plannerHourlyGrid'); if(!box) return;\n box.innerHTML=Array.from({length:24},(_,hour)=>`
${plannerHourLabel(hour)}Unlimited
`).join('');\n document.querySelectorAll('.planner-hour-input').forEach(input=>input.addEventListener('input',()=>updatePlannerHourSummary(Number(input.closest('.planner-hour-row')?.dataset.hour||0))));\n }\n function updatePlannerHourSummary(hour){ const down=Number($(`plannerHour${hour}Down`)?.value||0), up=Number($(`plannerHour${hour}Up`)?.value||0); const out=$(`plannerHour${hour}Summary`); if(out) out.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`; }\n function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function plannerSummaryValue(label, value){\n return `${esc(label)}: ${esc(value)}`;\n }\n\n // Note: Current Settings intentionally reuses the Poller Diagnostics row structure for matching radius, spacing and typography.\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const dryRun=$('plannerDryRun')?.checked;\n const nightStart=$('plannerNightStart')?.value || state.night_start || '--:--';\n const nightEnd=$('plannerNightEnd')?.value || state.night_end || '--:--';\n const quietStart=$('plannerQuietStart')?.value || state.quiet_start || '--:--';\n const quietEnd=$('plannerQuietEnd')?.value || state.quiet_end || '--:--';\n const items=[\n plannerSummaryValue('Status', `${enabled ? 'on' : 'off'}${dryRun ? ' / dry-run' : ''}`),\n plannerSummaryValue('Profile', $('plannerProfileName')?.value || state.profile_name || '-'),\n plannerSummaryValue('Hourly', plannerOnOff($('plannerHourlyEnabled')?.checked)),\n plannerSummaryValue('Night', `${plannerOnOff($('plannerNightOnly')?.checked)} ${nightStart}-${nightEnd}`),\n plannerSummaryValue('Quiet', `${plannerOnOff($('plannerQuietEnabled')?.checked)} ${quietStart}-${quietEnd}`),\n plannerSummaryValue('Protection', `CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`),\n ];\n box.innerHTML=`
Current settings${items.join('')}
`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' \u00b7 dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ updatePlannerCurrentSummary(preview); const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} \u00b7 next change ${esc(plannerDateText(preview.next_change_at))} \u00b7 DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' \u00b7 pauses downloads':''}${preview.manual_override_until?' \u00b7 override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); updatePlannerCurrentSummary(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const j=await post('/api/download-planner',plannerPayload()); fillPlanner(j.settings||plannerPayload()); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; +export const plannerSource = " function ensurePlannerToolsUI(){\n addToolTab('planner','fa-calendar-days','Planner','appstatus');\n addToolTab('poller','fa-satellite-dish','Poller','appstatus');\n const host=$('toolRss')?.parentElement || document.querySelector('#toolsModal .modal-body');\n if(!host) return;\n if(!$('toolPlanner')){\n const panel=document.createElement('div');\n panel.id='toolPlanner'; panel.className='d-none';\n panel.innerHTML=`
\n \n
\n
\n
\n
\n
\n
Download planner off
\n
${inlineSwitch('plannerEnabled')}
\n
\n
Current settingsLoading planner settings...
\n
\n
\n Basics\n
\n \n \n \n \n
\n
\n
\n Hourly speed planner\n ${plannerToggleRow('plannerHourlyEnabled','Use hourly speed limits','When enabled, the current hour overrides weekday and weekend speed limits.')}\n
\n
\n
\n
\n Fallback speed limits\n
${plannerSpeedCard('plannerWeekday','Weekday limits','Used when hourly planner is disabled')}${plannerSpeedCard('plannerWeekend','Weekend limits','Saturday and Sunday fallback')}
\n
\n
\n Time windows\n
\n ${plannerToggleRow('plannerNightOnly','Download only at night','Pause downloads outside the selected window.')}\n ${plannerToggleRow('plannerQuietEnabled','Quiet hours','Pause active downloads during the selected quiet window.')}\n
\n
\n \n \n \n \n
\n
\n
\n Protection\n
\n ${plannerToggleRow('plannerCpuEnabled','CPU protection','Pause downloads when CPU usage stays above the threshold for about 10 seconds.')}\n ${plannerToggleRow('plannerDiskEnabled','Disk protection','Pause downloads and block new download starts when disk usage is high.')}\n ${plannerToggleRow('plannerNetworkEnabled','Network protection','Clamp Planner speed limits to configured network caps.')}\n ${plannerToggleRow('plannerLoadEnabled','Load protection','Pause downloads when system load is above threshold.')}\n ${plannerToggleRow('plannerAutoResume','Auto resume planner-paused torrents','Resume only torrents paused by the planner when all protection rules become clear.')}\n
\n
\n \n \n \n \n \n
\n
\n
PreviewNo preview loaded.
\n
\n
\n
\n
\n
\n
\n
Action history
No actions yet.
\n
\n
\n
`\n host.appendChild(panel);\n renderPlannerHourlyGrid();\n // Note: Planner cards are collapsed by default; the summary bar keeps the active state visible.\n panel.addEventListener('change', e=>{ if(e.target.closest('#toolPlanner')) updatePlannerCurrentSummary(); });\n $('plannerSaveBtn')?.addEventListener('click',saveDownloadPlanner);\n $('plannerCheckBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(false));\n $('plannerDryRunBtn')?.addEventListener('click',()=>applyDownloadPlannerNow(true));\n $('plannerOverrideBtn')?.addEventListener('click',setPlannerOverride);\n $('plannerPreviewBtn')?.addEventListener('click',loadPlannerPreview);\n $('plannerHistory')?.addEventListener('click',async e=>{\n const toggle=e.target.closest('#plannerHistoryToggle');\n const clear=e.target.closest('#plannerHistoryClear');\n if(toggle){ plannerHistoryExpanded=!plannerHistoryExpanded; await loadPlannerPreview(); return; }\n if(clear && confirm('Clear Planner action history?')){\n try{ await post('/api/download-planner/history',{},'DELETE'); plannerHistoryExpanded=false; await loadPlannerPreview(); toast('Planner history cleared','success'); }\n catch(err){ toast(err.message,'danger'); }\n }\n });\n $('plannerProfileName')?.addEventListener('change',applyPlannerPreset);\n $('plannerHourCopyWeekday')?.addEventListener('click',()=>copyPlannerSpeedToHours('plannerWeekday'));\n document.querySelectorAll('.planner-hour-fill').forEach(btn=>btn.addEventListener('click',()=>fillPlannerHours(Number(btn.dataset.mbps||0))));\n setupPlannerSpeedControls();\n }\n if(!$('toolPoller')){\n const panel=document.createElement('div');\n panel.id='toolPoller'; panel.className='d-none';\n panel.innerHTML=`
\n
\n
Smart poller normal
Controls separate lightweight live polling and slower full torrent-list polling per active rTorrent profile.
\n
${inlineSwitch('pollerAdaptive')}
\n
\n
\n
\n Adaptive behavior\n
These values decide when the poller slows down, recovers after errors, or switches between active and idle refreshes.
\n
\n \n \n \n \n \n \n
\n
\n
\n Live poller\n
Fast lightweight loop for volatile data: speeds, torrent status changes and UI heartbeat. It should stay responsive, but not below the safe baseline on busy rTorrent instances.
\n
\n \n \n \n
\n
\n
\n Full poller\n
Slower loop for heavier work: full torrent snapshot/diff, tracker summary, disk state, queue, jobs and planner checks.
\n
\n \n \n \n \n
\n
\n ${plannerToggleRow('pollerSafeFallback','Safe fallback mode','When enabled, unsafe intervals are raised before saving. It protects rTorrent and the browser from too-aggressive refresh loops while keeping values that are already safe unchanged.')}\n
Enable Safe fallback mode to protect the app from too-aggressive poller intervals.
\n
DiagnosticsNot loaded.
\n
\n
\n
`;\n host.appendChild(panel);\n $('pollerSaveBtn')?.addEventListener('click',savePollerSettings);\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('input',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n document.querySelectorAll('#toolPoller input').forEach(input=>input.addEventListener('change',()=>{ if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview(); }));\n if(typeof updatePollerSafeFallbackPreview==='function') updatePollerSafeFallbackPreview();\n }\n }\n const plannerMbpsToBytes=mbps=>mbps?Math.round(Number(mbps)*1000000/8):0;\n const plannerBytesToMbps=bytes=>bytes?Math.round(Number(bytes)*8/1000000):0;\n function plannerLimitText(bytes){ const mbps=plannerBytesToMbps(Number(bytes||0)); return mbps?`${mbps} Mbit/s`:'Unlimited'; }\n function plannerHourLabel(hour){ return `${String(hour).padStart(2,'0')}:00-${String((hour+1)%24).padStart(2,'0')}:00`; }\n function renderPlannerHourlyGrid(){\n const box=$('plannerHourlyGrid'); if(!box) return;\n box.innerHTML=Array.from({length:24},(_,hour)=>`
${plannerHourLabel(hour)}Unlimited
`).join('');\n document.querySelectorAll('.planner-hour-input').forEach(input=>input.addEventListener('input',()=>updatePlannerHourSummary(Number(input.closest('.planner-hour-row')?.dataset.hour||0))));\n }\n function updatePlannerHourSummary(hour){ const down=Number($(`plannerHour${hour}Down`)?.value||0), up=Number($(`plannerHour${hour}Up`)?.value||0); const out=$(`plannerHour${hour}Summary`); if(out) out.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`; }\n function fillPlannerHours(mbps){ const bytes=plannerMbpsToBytes(mbps); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=bytes; if(u)u.value=bytes; updatePlannerHourSummary(hour); } }\n function copyPlannerSpeedToHours(prefix){ const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0); for(let hour=0;hour<24;hour++){ const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=down; if(u)u.value=up; updatePlannerHourSummary(hour); } }\n function plannerHourlyPayload(){ return Array.from({length:24},(_,hour)=>({hour,down:Number($(`plannerHour${hour}Down`)?.value||0),up:Number($(`plannerHour${hour}Up`)?.value||0)})); }\n function setPlannerSpeed(prefix,mbps){\n const bytes=plannerMbpsToBytes(mbps);\n ['Down','Up'].forEach(dir=>{ const input=$(`${prefix}${dir}`); if(input) input.value=bytes; });\n updatePlannerSpeedControls(prefix);\n }\n function updatePlannerSpeedControls(prefix){\n const down=Number($(`${prefix}Down`)?.value||0), up=Number($(`${prefix}Up`)?.value||0);\n [['Down',down],['Up',up]].forEach(([dir,value])=>{ const slider=$(`${prefix}${dir}Slider`), out=$(`${prefix}${dir}Mbps`); const mbps=plannerBytesToMbps(value); if(slider){ if(mbps>Number(slider.max||0)) slider.max=String(mbps); slider.value=String(mbps); } if(out) out.textContent=plannerLimitText(value); });\n const sum=$(`${prefix}Summary`); if(sum) sum.textContent=`DL ${plannerLimitText(down)} / UL ${plannerLimitText(up)}`;\n }\n function setupPlannerSpeedControls(){\n document.querySelectorAll('.planner-speed-preset').forEach(btn=>btn.addEventListener('click',()=>setPlannerSpeed(btn.dataset.prefix,Number(btn.dataset.mbps||0))));\n document.querySelectorAll('.planner-mbps-slider').forEach(slider=>slider.addEventListener('input',()=>{ const target=$(slider.dataset.target); if(target) target.value=plannerMbpsToBytes(Number(slider.value||0)); const prefix=(slider.dataset.target||'').replace(/(Down|Up)$/,''); updatePlannerSpeedControls(prefix); }));\n document.querySelectorAll('.planner-byte-input').forEach(input=>input.addEventListener('input',()=>updatePlannerSpeedControls(input.id.replace(/(Down|Up)$/,''))));\n }\n function plannerPayload(){ return {enabled:$('plannerEnabled')?.checked,profile_name:$('plannerProfileName')?.value||'night mode',dry_run:$('plannerDryRun')?.checked,night_only_enabled:$('plannerNightOnly')?.checked,night_start:$('plannerNightStart')?.value||'23:00',night_end:$('plannerNightEnd')?.value||'07:00',quiet_hours_enabled:$('plannerQuietEnabled')?.checked,quiet_start:$('plannerQuietStart')?.value||'22:00',quiet_end:$('plannerQuietEnd')?.value||'06:00',weekday_down:Number($('plannerWeekdayDown')?.value||0),weekday_up:Number($('plannerWeekdayUp')?.value||0),weekend_down:Number($('plannerWeekendDown')?.value||0),weekend_up:Number($('plannerWeekendUp')?.value||0),hourly_schedule_enabled:$('plannerHourlyEnabled')?.checked,hourly_schedule:plannerHourlyPayload(),auto_pause_cpu_enabled:$('plannerCpuEnabled')?.checked,auto_pause_cpu_percent:Number($('plannerCpuPercent')?.value||90),auto_pause_disk_enabled:$('plannerDiskEnabled')?.checked,auto_pause_disk_percent:Number($('plannerDiskPercent')?.value||95),network_protection_enabled:$('plannerNetworkEnabled')?.checked,network_max_down:Number($('plannerNetworkDown')?.value||0),network_max_up:Number($('plannerNetworkUp')?.value||0),load_protection_enabled:$('plannerLoadEnabled')?.checked,load_cpu_percent:Number($('plannerLoadCpu')?.value||95),auto_resume:$('plannerAutoResume')?.checked,auto_resume_grace_seconds:Number($('plannerResumeGrace')?.value||0)}; }\n function plannerOnOff(value){ return value ? 'on' : 'off'; }\n function plannerSummaryValue(label, value){\n return `${esc(label)}: ${esc(value)}`;\n }\n\n // Note: Current Settings intentionally reuses the Poller Diagnostics row structure for matching radius, spacing and typography.\n function updatePlannerCurrentSummary(state={}){\n const box=$('plannerCurrentSummary');\n if(!box) return;\n const enabled=$('plannerEnabled')?.checked ?? !!state.enabled;\n const dryRun=$('plannerDryRun')?.checked;\n const nightStart=$('plannerNightStart')?.value || state.night_start || '--:--';\n const nightEnd=$('plannerNightEnd')?.value || state.night_end || '--:--';\n const quietStart=$('plannerQuietStart')?.value || state.quiet_start || '--:--';\n const quietEnd=$('plannerQuietEnd')?.value || state.quiet_end || '--:--';\n const items=[\n plannerSummaryValue('Status', `${enabled ? 'on' : 'off'}${dryRun ? ' / dry-run' : ''}`),\n plannerSummaryValue('Profile', $('plannerProfileName')?.value || state.profile_name || '-'),\n plannerSummaryValue('Hourly', plannerOnOff($('plannerHourlyEnabled')?.checked)),\n plannerSummaryValue('Night', `${plannerOnOff($('plannerNightOnly')?.checked)} ${nightStart}-${nightEnd}`),\n plannerSummaryValue('Quiet', `${plannerOnOff($('plannerQuietEnabled')?.checked)} ${quietStart}-${quietEnd}`),\n plannerSummaryValue('Protection', `CPU ${plannerOnOff($('plannerCpuEnabled')?.checked)}, disk ${plannerOnOff($('plannerDiskEnabled')?.checked)}, network ${plannerOnOff($('plannerNetworkEnabled')?.checked)}, load ${plannerOnOff($('plannerLoadEnabled')?.checked)}`),\n ];\n box.innerHTML=`
Current settings${items.join('')}
`;\n }\n\n function updatePlannerFooter(enabled,preview={}){ updatePlannerCurrentSummary(preview); const btn=$('statusPlannerOpen'); if(btn){ btn.classList.toggle('d-none',!enabled); btn.classList.toggle('text-warning',!!preview.manual_override_until); btn.title=enabled?`Planner ${preview.matched_rule||'enabled'}${preview.dry_run?' \u00b7 dry-run':''}`:'Download planner is disabled.'; const span=btn.querySelector('span'); if(span) span.textContent=preview.dry_run?'Planner dry-run':preview.manual_override_until?'Planner paused':'Planner'; } const badge=$('plannerStatusBadge'); if(badge){ badge.className=`badge ${enabled?'text-bg-success':'text-bg-secondary'}`; badge.textContent=enabled?(preview.dry_run?'dry-run':preview.manual_override_until?'override':'enabled'):'off'; } }\n function plannerDateText(value){ if(!value) return '-'; if(typeof value==='number') return formatDateTime(value); const d=new Date(value); return isNaN(d.getTime())?'-':d.toLocaleString(); }\n function renderPlannerPreview(preview={}){ updatePlannerCurrentSummary(preview); const box=$('plannerPreview'); if(!box)return; const down=plannerLimitText(preview.down||0), up=plannerLimitText(preview.up||0); box.innerHTML=`Matched ${esc(preview.matched_rule||'-')} \u00b7 next change ${esc(plannerDateText(preview.next_change_at))} \u00b7 DL ${esc(down)} / UL ${esc(up)}${preview.pause_downloads?' \u00b7 pauses downloads':''}${preview.manual_override_until?' \u00b7 override active':''}`; updatePlannerFooter(!!$('plannerEnabled')?.checked,preview); const ov=$('plannerOverrideStatus'); if(ov) ov.textContent=preview.manual_override_until?`Active until ${plannerDateText(preview.manual_override_until)}`:'No active override.'; }\n function plannerHistoryDetails(row={}){ return row && typeof row==='object' ? row : {}; }\n function plannerHistoryLimitText(value){ return plannerLimitText(Number(value||0)); }\n function renderPlannerHistory(items=[], total=items.length){\n const box=$('plannerHistory'); if(!box)return;\n const body=items.length\n ? responsiveTable(['Time','Event','Rule','DL','UL','Paused','Resumed','Dry run','Reason'],items.map(x=>{\n // Note: Planner history uses the same table pattern as Smart Queue, with compact decision columns first.\n const d=plannerHistoryDetails(x);\n const event=d.event||'-';\n const rule=d.rule||d.matched_rule||d.profile_name||'-';\n const down=d.down!==undefined?plannerHistoryLimitText(d.down):'-';\n const up=d.up!==undefined?plannerHistoryLimitText(d.up):'-';\n const paused=d.paused ?? d.count ?? 0;\n const resumed=d.resumed ?? 0;\n const dry=d.dry_run?'yes':'-';\n const reason=d.pause_reason||d.reason||d.manual_override_reason||'-';\n return [dateCell(d.at),esc(event),esc(rule),esc(down),esc(up),esc(paused),esc(resumed),esc(dry),esc(reason)];\n }),'planner-history-table')\n : '
No Planner actions yet.
';\n const canToggle=Number(total||0)>10;\n const toggle=canToggle?``:'';\n const clear=Number(total||0)?``:'';\n box.innerHTML=`${body}${toggle}${clear}`;\n }\n function fillPlanner(st){ if(!st)return; $('plannerEnabled')&&($('plannerEnabled').checked=!!st.enabled); $('plannerProfileName')&&($('plannerProfileName').value=st.profile_name||'night mode'); $('plannerDryRun')&&($('plannerDryRun').checked=!!st.dry_run); updatePlannerFooter(!!st.enabled,st); $('plannerHourlyEnabled')&&($('plannerHourlyEnabled').checked=!!st.hourly_schedule_enabled); const hourly=Array.isArray(st.hourly_schedule)?st.hourly_schedule:[]; for(let hour=0;hour<24;hour++){ const item=hourly.find(x=>Number(x.hour)===hour)||{}; const d=$(`plannerHour${hour}Down`), u=$(`plannerHour${hour}Up`); if(d)d.value=Number(item.down||0); if(u)u.value=Number(item.up||0); updatePlannerHourSummary(hour); } $('plannerNightOnly')&&($('plannerNightOnly').checked=!!st.night_only_enabled); $('plannerNightStart')&&($('plannerNightStart').value=st.night_start||'23:00'); $('plannerNightEnd')&&($('plannerNightEnd').value=st.night_end||'07:00'); $('plannerQuietEnabled')&&($('plannerQuietEnabled').checked=!!st.quiet_hours_enabled); $('plannerQuietStart')&&($('plannerQuietStart').value=st.quiet_start||'22:00'); $('plannerQuietEnd')&&($('plannerQuietEnd').value=st.quiet_end||'06:00'); $('plannerWeekdayDown')&&($('plannerWeekdayDown').value=st.weekday_down||0); $('plannerWeekdayUp')&&($('plannerWeekdayUp').value=st.weekday_up||0); $('plannerWeekendDown')&&($('plannerWeekendDown').value=st.weekend_down||0); $('plannerWeekendUp')&&($('plannerWeekendUp').value=st.weekend_up||0); updatePlannerSpeedControls('plannerWeekday'); updatePlannerSpeedControls('plannerWeekend'); $('plannerCpuEnabled')&&($('plannerCpuEnabled').checked=!!st.auto_pause_cpu_enabled); $('plannerCpuPercent')&&($('plannerCpuPercent').value=st.auto_pause_cpu_percent||90); $('plannerDiskEnabled')&&($('plannerDiskEnabled').checked=!!st.auto_pause_disk_enabled); $('plannerDiskPercent')&&($('plannerDiskPercent').value=st.auto_pause_disk_percent||95); $('plannerNetworkEnabled')&&($('plannerNetworkEnabled').checked=!!st.network_protection_enabled); $('plannerNetworkDown')&&($('plannerNetworkDown').value=st.network_max_down||0); $('plannerNetworkUp')&&($('plannerNetworkUp').value=st.network_max_up||0); $('plannerLoadEnabled')&&($('plannerLoadEnabled').checked=!!st.load_protection_enabled); $('plannerLoadCpu')&&($('plannerLoadCpu').value=st.load_cpu_percent||95); $('plannerAutoResume')&&($('plannerAutoResume').checked=st.auto_resume!==false); $('plannerResumeGrace')&&($('plannerResumeGrace').value=st.auto_resume_grace_seconds||0); if(st.manual_override_until) renderPlannerPreview(st); updatePlannerCurrentSummary(st); }\n function applyPlannerPreset(){ const name=$('plannerProfileName')?.value||''; if(name==='night mode'){ $('plannerNightOnly').checked=true; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',100); setPlannerSpeed('plannerWeekend',250); } if(name==='weekend mode'){ $('plannerNightOnly').checked=false; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',0); } if(name==='low power mode'){ $('plannerLoadEnabled').checked=true; $('plannerCpuEnabled').checked=true; $('plannerCpuPercent').value=70; setPlannerSpeed('plannerWeekday',50); setPlannerSpeed('plannerWeekend',50); } if(name==='unlimited mode'){ $('plannerNightOnly').checked=false; $('plannerQuietEnabled').checked=false; setPlannerSpeed('plannerWeekday',0); setPlannerSpeed('plannerWeekend',0); } }\n async function loadPlannerPreview(){ try{const limit=plannerHistoryExpanded?100:10; const j=await fetch(`/api/download-planner/preview?history_limit=${limit}`).then(r=>r.json()); renderPlannerPreview(j.preview||{}); renderPlannerHistory(j.history||[], Number(j.history_total ?? (j.history||[]).length));}catch(e){} }\n async function loadDownloadPlanner(){ ensurePlannerToolsUI(); try{const j=await fetch('/api/download-planner').then(r=>r.json()); fillPlanner(j.settings||{}); await loadPlannerPreview();}catch(e){} }\n async function saveDownloadPlanner(){ try{const j=await post('/api/download-planner',plannerPayload()); fillPlanner(j.settings||plannerPayload()); await loadPlannerPreview(); toast('Download planner saved','success');}catch(e){toast(e.message,'danger');} }\n async function applyDownloadPlannerNow(dryRun=false){ try{const j=await post('/api/download-planner/check',{dry_run:!!dryRun}); const r=j.result||{}; if(r.settings) fillPlanner(r.settings); renderPlannerPreview(r.preview||r); if(r.history) renderPlannerHistory(r.history, r.history_total ?? r.history.length); else await loadPlannerPreview(); toastMessage('toast.plannerApplied','success',{dryRun,paused:r.paused,resumed:r.resumed,limitsChanged:r.limits_changed});}catch(e){toast(e.message,'danger');} }\n async function setPlannerOverride(){ try{const seconds=Number($('plannerOverrideSeconds')?.value||0); await post('/api/download-planner/override',{seconds}); toast(seconds?'Planner override set':'Planner override cleared','success'); await loadDownloadPlanner();}catch(e){toast(e.message,'danger');} }\n"; diff --git a/pytorrent/static/js/pollerSettings.js b/pytorrent/static/js/pollerSettings.js index 8fbe08d..d35164f 100644 --- a/pytorrent/static/js/pollerSettings.js +++ b/pytorrent/static/js/pollerSettings.js @@ -1 +1 @@ -export const pollerSettingsSource = " function pollerPayload(){return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,active_interval_seconds:Number($('pollerActive')?.value||3),idle_interval_seconds:Number($('pollerIdle')?.value||15),error_interval_seconds:Number($('pollerError')?.value||30),live_stats_interval_seconds:Number($('pollerLiveStats')?.value||3),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||30),system_stats_interval_seconds:Number($('pollerSystem')?.value||1),tracker_stats_interval_seconds:Number($('pollerTracker')?.value||30),disk_stats_interval_seconds:Number($('pollerDisk')?.value||30),queue_stats_interval_seconds:Number($('pollerQueue')?.value||5),slow_stats_interval_seconds:Number($('pollerQueue')?.value||5),heartbeat_interval_seconds:Number($('pollerHeartbeat')?.value||5),slow_response_threshold_ms:Number($('pollerSlowThreshold')?.value||10000),slowdown_multiplier:Number($('pollerSlowdown')?.value||1),recovery_after_errors:Number($('pollerRecoveryErrors')?.value||3),emit_heartbeat_on_change:true};}\n function updatePollerBadge(rt={}){ const badge=$('pollerStatusBadge'); if(!badge)return; const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed'; badge.className=`badge ${mode==='recovery'?'text-bg-danger':mode==='slowdown'?'text-bg-warning':mode==='idle'||mode==='fixed'?'text-bg-secondary':'text-bg-success'}`; badge.textContent=mode==='fixed'?'fixed interval':mode; }\n const POLLER_SAFE_BASELINE={pollerActive:3,pollerIdle:15,pollerError:30,pollerLiveStats:3,pollerTorrentList:30,pollerSystem:5,pollerTracker:300,pollerDisk:60,pollerQueue:15,pollerHeartbeat:15};\n const POLLER_SAFE_LABELS={pollerActive:'Active interval',pollerIdle:'Idle interval',pollerError:'Error interval',pollerLiveStats:'Live stats',pollerTorrentList:'Torrent list',pollerSystem:'System stats',pollerTracker:'Tracker stats',pollerDisk:'Disk stats',pollerQueue:'Queue/job stats',pollerHeartbeat:'Heartbeat'};\n function pollerInputNumber(id){ return Number($(id)?.value||0); }\n function updatePollerSafeFallbackPreview(){ const box=$('pollerSafeFallbackPreview'); if(!box) return; const enabled=$('pollerSafeFallback')?.checked!==false; const changes=Object.entries(POLLER_SAFE_BASELINE).filter(([id,min])=>pollerInputNumber(id)>0 && pollerInputNumber(id)`${POLLER_SAFE_LABELS[id]} ${esc(pollerInputNumber(id))}s → ${esc(min)}s`); if(!enabled){ box.innerHTML='Safe fallback mode is off. Saved values can use the normal backend limits, including more aggressive intervals.'; return; } box.innerHTML=changes.length?`Safe fallback will change: ${changes.join(', ')}. Other values stay unchanged.`:'Safe fallback is on. Current values are already within the safe baseline, so saving will keep them unchanged.'; }\n function fillPoller(st,rt){ if(!st){ const merged={...(rt||{})}; if($('pollerAdaptive') && merged.adaptive_enabled===undefined) merged.adaptive_enabled=$('pollerAdaptive').checked; if(rt && $('pollerRuntime')) $('pollerRuntime').innerHTML=pollerDiagnostics(merged); updatePollerBadge(merged); updatePollerSafeFallbackPreview(); return; } $('pollerAdaptive')&&($('pollerAdaptive').checked=!!st.adaptive_enabled); $('pollerSafeFallback')&&($('pollerSafeFallback').checked=st.safe_fallback_enabled!==false); $('pollerActive')&&($('pollerActive').value=st.active_interval_seconds??3); $('pollerIdle')&&($('pollerIdle').value=st.idle_interval_seconds??15); $('pollerError')&&($('pollerError').value=st.error_interval_seconds??30); $('pollerLiveStats')&&($('pollerLiveStats').value=st.live_stats_interval_seconds??3); $('pollerTorrentList')&&($('pollerTorrentList').value=st.torrent_list_interval_seconds??30); $('pollerSystem')&&($('pollerSystem').value=st.system_stats_interval_seconds??5); $('pollerTracker')&&($('pollerTracker').value=st.tracker_stats_interval_seconds??300); $('pollerDisk')&&($('pollerDisk').value=st.disk_stats_interval_seconds||60); $('pollerQueue')&&($('pollerQueue').value=st.queue_stats_interval_seconds??15); $('pollerHeartbeat')&&($('pollerHeartbeat').value=st.heartbeat_interval_seconds??15); $('pollerSlowThreshold')&&($('pollerSlowThreshold').value=st.slow_response_threshold_ms??8000); $('pollerSlowdown')&&($('pollerSlowdown').value=st.slowdown_multiplier??2); $('pollerRecoveryErrors')&&($('pollerRecoveryErrors').value=st.recovery_after_errors||3); if($('pollerRuntime')) $('pollerRuntime').innerHTML=rt?pollerDiagnostics({...rt,adaptive_enabled:st.adaptive_enabled}):''; updatePollerBadge(rt?{...rt,adaptive_enabled:st.adaptive_enabled}:{adaptive_enabled:st.adaptive_enabled}); updatePollerSafeFallbackPreview(); }\n function pollerDiagnosticItem(label,value){ return `${esc(label)}: ${value}`; }\n function pollerDiagnosticGroup(title,items){ return `${esc(title)}${items.join('')}`; }\n function pollerDiagnostics(rt={}){ const adaptive=rt.adaptive_enabled!==false; const mode=adaptive?(rt.adaptive_mode||'normal'):'fixed interval'; const live=[pollerDiagnosticItem('Polls',esc(rt.live_poll_count||0)),pollerDiagnosticItem('Last',`${esc(rt.last_live_duration_ms||0)} ms`),pollerDiagnosticItem('Updated',esc(rt.last_live_updated_count||0)),pollerDiagnosticItem('Full refresh',rt.last_live_requires_full_refresh?'yes':'no'),pollerDiagnosticItem('Interval',`${esc(rt.live_stats_interval_seconds||rt.effective_interval_seconds||0)}s`)]; const full=[pollerDiagnosticItem('Polls',esc(rt.list_poll_count||0)),pollerDiagnosticItem('Last',`${esc(rt.last_list_duration_ms||0)} ms`),pollerDiagnosticItem('Added/updated/removed',`${esc(rt.last_list_added_count||0)}/${esc(rt.last_list_updated_count||0)}/${esc(rt.last_list_removed_count||0)}`),pollerDiagnosticItem('Interval',`${esc(rt.torrent_list_interval_seconds||0)}s`)]; const runtime=[pollerDiagnosticItem('Duration',`${esc(rt.duration_ms||rt.last_tick_ms||0)} ms`),pollerDiagnosticItem('Gap',`${esc(rt.last_tick_gap_ms||0)} ms`),pollerDiagnosticItem('Payload',esc(fmtBytes(rt.emitted_payload_size||0))),pollerDiagnosticItem('rTorrent calls',esc(rt.rtorrent_call_count||0)),pollerDiagnosticItem('Skipped',esc(rt.skipped_emissions||0)),pollerDiagnosticItem('Ticks',esc(rt.tick_count||0))]; const state=[pollerDiagnosticItem('Mode',esc(mode)),pollerDiagnosticItem('Adaptive',adaptive?'on':'off'),pollerDiagnosticItem('OK',rt.last_ok?'yes':'no'),pollerDiagnosticItem('Minimum',`${esc(rt.configured_min_interval_seconds||0)}s`)]; return [pollerDiagnosticGroup('Live poller',live),pollerDiagnosticGroup('Full poller',full),pollerDiagnosticGroup('Runtime',runtime),pollerDiagnosticGroup('State',state)].join(''); }\n async function loadPollerSettings(){ "; +export const pollerSettingsSource = " function pollerPayload(){return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,active_interval_seconds:Number($('pollerActive')?.value||3),idle_interval_seconds:Number($('pollerIdle')?.value||15),error_interval_seconds:Number($('pollerError')?.value||30),live_stats_interval_seconds:Number($('pollerLiveStats')?.value||3),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||30),system_stats_interval_seconds:Number($('pollerSystem')?.value||1),tracker_stats_interval_seconds:Number($('pollerTracker')?.value||30),disk_stats_interval_seconds:Number($('pollerDisk')?.value||30),queue_stats_interval_seconds:Number($('pollerQueue')?.value||5),slow_stats_interval_seconds:Number($('pollerQueue')?.value||5),heartbeat_interval_seconds:Number($('pollerHeartbeat')?.value||5),slow_response_threshold_ms:Number($('pollerSlowThreshold')?.value||10000),slowdown_multiplier:Number($('pollerSlowdown')?.value||1),recovery_after_errors:Number($('pollerRecoveryErrors')?.value||3),emit_heartbeat_on_change:true};}\n let lastPollerRuntime = {};\n function hasPollerRuntime(rt={}){ return !!(rt && (rt.runtime_ready || Number(rt.tick_count||0)>0 || Number(rt.live_poll_count||0)>0 || Number(rt.list_poll_count||0)>0)); }\n function currentPollerSettingsFromForm(){ return {adaptive_enabled:$('pollerAdaptive')?.checked,safe_fallback_enabled:$('pollerSafeFallback')?.checked,live_stats_interval_seconds:Number($('pollerLiveStats')?.value||3),torrent_list_interval_seconds:Number($('pollerTorrentList')?.value||30),configured_min_interval_seconds:Number($('pollerActive')?.min||0.5)}; }\n function pollerRuntimeWithSettings(st={},rt={}){\n // Note: Manual refresh can arrive before the backend tick rebuilds counters; keep the last live runtime in that case.\n const runtime = hasPollerRuntime(rt) ? rt : (hasPollerRuntime(lastPollerRuntime) ? lastPollerRuntime : (rt || {}));\n const merged = {...runtime};\n if(merged.adaptive_enabled===undefined) merged.adaptive_enabled = st.adaptive_enabled;\n if(!Number(merged.live_stats_interval_seconds||0)) merged.live_stats_interval_seconds = st.live_stats_interval_seconds;\n if(!Number(merged.torrent_list_interval_seconds||0)) merged.torrent_list_interval_seconds = st.torrent_list_interval_seconds;\n if(!Number(merged.configured_min_interval_seconds||0)) merged.configured_min_interval_seconds = 0.5;\n if(!hasPollerRuntime(merged)) merged.runtime_ready = false;\n if(hasPollerRuntime(merged)) lastPollerRuntime = {...merged};\n return merged;\n }\n function updatePollerBadge(rt={}){ const badge=$('pollerStatusBadge'); if(!badge)return; const adaptive=rt.adaptive_enabled!==false; const pending=rt.runtime_ready===false && !hasPollerRuntime(rt); const mode=pending?'waiting':(adaptive?(rt.adaptive_mode||'normal'):'fixed'); badge.className=`badge ${mode==='recovery'?'text-bg-danger':mode==='slowdown'?'text-bg-warning':mode==='idle'||mode==='fixed'||mode==='waiting'?'text-bg-secondary':'text-bg-success'}`; badge.textContent=mode==='fixed'?'fixed interval':mode; }\n const POLLER_SAFE_BASELINE={pollerActive:3,pollerIdle:15,pollerError:30,pollerLiveStats:3,pollerTorrentList:30,pollerSystem:5,pollerTracker:300,pollerDisk:60,pollerQueue:15,pollerHeartbeat:15};\n const POLLER_SAFE_LABELS={pollerActive:'Active interval',pollerIdle:'Idle interval',pollerError:'Error interval',pollerLiveStats:'Live stats',pollerTorrentList:'Torrent list',pollerSystem:'System stats',pollerTracker:'Tracker stats',pollerDisk:'Disk stats',pollerQueue:'Queue/job stats',pollerHeartbeat:'Heartbeat'};\n function pollerInputNumber(id){ return Number($(id)?.value||0); }\n function updatePollerSafeFallbackPreview(){ const box=$('pollerSafeFallbackPreview'); if(!box) return; const enabled=$('pollerSafeFallback')?.checked!==false; const changes=Object.entries(POLLER_SAFE_BASELINE).filter(([id,min])=>pollerInputNumber(id)>0 && pollerInputNumber(id)`${POLLER_SAFE_LABELS[id]} ${esc(pollerInputNumber(id))}s \u2192 ${esc(min)}s`); if(!enabled){ box.innerHTML='Safe fallback mode is off. Saved values can use the normal backend limits, including more aggressive intervals.'; return; } box.innerHTML=changes.length?`Safe fallback will change: ${changes.join(', ')}. Other values stay unchanged.`:'Safe fallback is on. Current values are already within the safe baseline, so saving will keep them unchanged.'; }\n function fillPoller(st,rt){ const settings=st || currentPollerSettingsFromForm(); const merged=pollerRuntimeWithSettings(settings,rt||{}); if(!st){ if($('pollerAdaptive') && merged.adaptive_enabled===undefined) merged.adaptive_enabled=$('pollerAdaptive').checked; if($('pollerRuntime')) $('pollerRuntime').innerHTML=pollerDiagnostics(merged); updatePollerBadge(merged); updatePollerSafeFallbackPreview(); return; } $('pollerAdaptive')&&($('pollerAdaptive').checked=!!settings.adaptive_enabled); $('pollerSafeFallback')&&($('pollerSafeFallback').checked=settings.safe_fallback_enabled!==false); $('pollerActive')&&($('pollerActive').value=settings.active_interval_seconds??3); $('pollerIdle')&&($('pollerIdle').value=settings.idle_interval_seconds??15); $('pollerError')&&($('pollerError').value=settings.error_interval_seconds??30); $('pollerLiveStats')&&($('pollerLiveStats').value=settings.live_stats_interval_seconds??3); $('pollerTorrentList')&&($('pollerTorrentList').value=settings.torrent_list_interval_seconds??30); $('pollerSystem')&&($('pollerSystem').value=settings.system_stats_interval_seconds??5); $('pollerTracker')&&($('pollerTracker').value=settings.tracker_stats_interval_seconds??300); $('pollerDisk')&&($('pollerDisk').value=settings.disk_stats_interval_seconds||60); $('pollerQueue')&&($('pollerQueue').value=settings.queue_stats_interval_seconds??15); $('pollerHeartbeat')&&($('pollerHeartbeat').value=settings.heartbeat_interval_seconds??15); $('pollerSlowThreshold')&&($('pollerSlowThreshold').value=settings.slow_response_threshold_ms??8000); $('pollerSlowdown')&&($('pollerSlowdown').value=settings.slowdown_multiplier??2); $('pollerRecoveryErrors')&&($('pollerRecoveryErrors').value=settings.recovery_after_errors||3); if($('pollerRuntime')) $('pollerRuntime').innerHTML=pollerDiagnostics(merged); updatePollerBadge(merged); updatePollerSafeFallbackPreview(); }\n function pollerDiagnosticItem(label,value){ return `${esc(label)}: ${value}`; }\n function pollerDiagnosticGroup(title,items){ return `${esc(title)}${items.join('')}`; }\n function pollerDiagnostics(rt={}){ const adaptive=rt.adaptive_enabled!==false; const pending=rt.runtime_ready===false && !hasPollerRuntime(rt); const mode=pending?'waiting':(adaptive?(rt.adaptive_mode||'normal'):'fixed interval'); const duration=pending?'waiting':`${esc(rt.duration_ms||rt.last_tick_ms||0)} ms`; const gap=pending?'waiting':`${esc(rt.last_tick_gap_ms||0)} ms`; const calls=pending?'waiting':esc(rt.rtorrent_call_count||0); const payload=pending?'waiting':esc(fmtBytes(rt.emitted_payload_size||0)); const live=[pollerDiagnosticItem('Polls',esc(rt.live_poll_count||0)),pollerDiagnosticItem('Last',pending?'waiting':`${esc(rt.last_live_duration_ms||0)} ms`),pollerDiagnosticItem('Updated',esc(rt.last_live_updated_count||0)),pollerDiagnosticItem('Full refresh',rt.last_live_requires_full_refresh?'yes':'no'),pollerDiagnosticItem('Interval',`${esc(rt.live_stats_interval_seconds||rt.effective_interval_seconds||0)}s`)]; const full=[pollerDiagnosticItem('Polls',esc(rt.list_poll_count||0)),pollerDiagnosticItem('Last',pending?'waiting':`${esc(rt.last_list_duration_ms||0)} ms`),pollerDiagnosticItem('Added/updated/removed',`${esc(rt.last_list_added_count||0)}/${esc(rt.last_list_updated_count||0)}/${esc(rt.last_list_removed_count||0)}`),pollerDiagnosticItem('Interval',`${esc(rt.torrent_list_interval_seconds||0)}s`)]; const runtime=[pollerDiagnosticItem('Duration',duration),pollerDiagnosticItem('Gap',gap),pollerDiagnosticItem('Payload',payload),pollerDiagnosticItem('rTorrent calls',calls),pollerDiagnosticItem('Skipped',esc(rt.skipped_emissions||0)),pollerDiagnosticItem('Ticks',esc(rt.tick_count||0))]; const state=[pollerDiagnosticItem('Mode',esc(mode)),pollerDiagnosticItem('Adaptive',adaptive?'on':'off'),pollerDiagnosticItem('OK',pending?'waiting':(rt.last_ok?'yes':'no')),pollerDiagnosticItem('Minimum',`${esc(rt.configured_min_interval_seconds||0)}s`)]; return [pollerDiagnosticGroup('Live poller',live),pollerDiagnosticGroup('Full poller',full),pollerDiagnosticGroup('Runtime',runtime),pollerDiagnosticGroup('State',state)].join(''); }\n async function loadPollerSettings(){ "; diff --git a/pytorrent/static/js/profileList.js b/pytorrent/static/js/profileList.js index 7adbc61..41fc4cf 100644 --- a/pytorrent/static/js/profileList.js +++ b/pytorrent/static/js/profileList.js @@ -1 +1 @@ -export const profileListSource = " 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; const backupBadge=p.profile_backup_enabled?` profile backup on`:''; return `
#${esc(p.id)} ${esc(p.name)} active ${p.is_remote?\"remote\":''}${backupBadge} ${esc(st)}ID ${esc(p.id)} · ${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"; +export const profileListSource = " 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; const backupBadge=p.profile_backup_enabled?` `:''; return `
#${esc(p.id)} ${esc(p.name)} active ${p.is_remote?\"remote\":''}${backupBadge} ${esc(st)}ID ${esc(p.id)} \u00b7 ${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"; diff --git a/pytorrent/static/js/profiles.js b/pytorrent/static/js/profiles.js index 5d47b01..0f3b4dc 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?` \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 clearProfileScopedTorrentView('Loading torrents...');\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; const backupBadge=p.profile_backup_enabled?` `:''; return `
${esc(p.name)} active ${p.is_remote?\"remote\":''}${backupBadge} ${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 clearProfileScopedTorrentView('Loading torrents...');\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/toolsModal.js b/pytorrent/static/js/toolsModal.js index 26a84db..a20890c 100644 --- a/pytorrent/static/js/toolsModal.js +++ b/pytorrent/static/js/toolsModal.js @@ -1 +1 @@ -export const toolsModalSource = "ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings').then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); "; +export const toolsModalSource = "ensurePlannerToolsUI(); try{const j=await fetch('/api/poller/settings',{cache:'no-store'}).then(r=>r.json()); fillPoller(j.settings||{},j.runtime||{});}catch(e){} }\n async function savePollerSettings(){ try{const j=await post('/api/poller/settings',pollerPayload()); fillPoller(j.settings||pollerPayload(),j.runtime||null); toast('Poller settings saved','success');}catch(e){toast(e.message,'danger');} }\n ensurePlannerToolsUI(); ensureDashboardToolsUI(); loadDownloadPlanner(); $('toolsModal')?.addEventListener('show.bs.modal',()=>{ensurePlannerToolsUI();ensureDashboardToolsUI();refreshProfiles();loadLabels();loadRatios();loadRss();loadSmartQueue();loadRtConfig();loadAutomations();loadCleanup();loadBackup();loadAppStatus();loadOperationLogs();renderHealthDashboard();renderSmartViewsManager();renderNotificationCenter();loadPreferences();loadJobSettings();if(document.querySelector('.tool-tab[data-tool=\"users\"]')?.classList.contains('active')) loadAuthUsers();loadDownloadPlanner();loadPollerSettings();renderColumnManager();applyColumnVisibility();updateAutomationForm();}); const toolPanelIds={rtorrents:'toolRtorrents',settings:'toolRtorrents',torrentstats:'toolTorrentStats',preferences:'toolPreferences',jobs:'toolJobs',users:'toolUsers',labels:'toolLabels',ratio:'toolRatio',rss:'toolRss',columns:'toolColumns',smart:'toolSmart',automations:'toolAutomations',rtconfig:'toolRtconfig',cleanup:'toolCleanup',backup:'toolBackup',logs:'toolLogs',appstatus:'toolAppstatus',planner:'toolPlanner',poller:'toolPoller',smartviews:'toolSmartviews',notifications:'toolNotifications'}; const hideToolPanels=()=>Object.values(toolPanelIds).filter((v,i,a)=>a.indexOf(v)===i).forEach(id=>$(id)?.classList.add('d-none')); const showToolPanel=tool=>{hideToolPanels(); $(toolPanelIds[tool]||'toolRtorrents')?.classList.remove('d-none');}; const activateToolTab=tool=>{document.querySelectorAll('.tool-tab').forEach(x=>x.classList.toggle('active',(x.dataset.tool||'rtorrents')===tool)); showToolPanel(tool); if(tool==='torrentstats') loadTorrentStats(false); if(tool==='appstatus') loadAppStatus(); if(tool==='cleanup') loadCleanup(); if(tool==='backup') loadBackup(); if(tool==='preferences') loadPreferences(); if(tool==='jobs') loadJobSettings(); if(tool==='logs') loadOperationLogs(true); if(tool==='users') loadAuthUsers(); if(tool==='planner') loadDownloadPlanner(); if(tool==='poller') loadPollerSettings(); if(tool==='smartviews') renderSmartViewsManager(); if(tool==='notifications') renderNotificationCenter(); if(tool==='diagnostics') loadAppStatus(); }; document.querySelectorAll('.tool-tab').forEach(b=>b.addEventListener('click',()=>activateToolTab(b.dataset.tool||'rtorrents'))); bindOperationLogEvents(); "; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index 4247025..5b15e3e 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -5885,6 +5885,16 @@ body.compact-torrent-list .mobile-progress .torrent-progress { gap: 0.45rem 0.85rem; } + +.profile-backup-icon { + align-items: center; + color: var(--bs-info); + display: inline-flex; + font-size: 0.82rem; + margin-left: 0.25rem; + vertical-align: middle; +} + .profile-id-badge { display: inline-flex; align-items: center;