This commit is contained in:
Mateusz Gruszczyński
2026-06-16 22:45:44 +02:00
parent d5fa689dad
commit 03ce088d24
9 changed files with 29 additions and 6 deletions
+1 -1
View File
@@ -1 +1 @@
export const appStatusSource = " async function loadAppStatus(){\n const box=$('appStatusManager'); if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> 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=`<div class=\"column-manager-tabs appstatus-tabs\"><ul class=\"nav nav-pills\">${panes.map((p,i)=>`<li class=\"nav-item\"><button class=\"nav-link ${i?'':'active'}\" type=\"button\" data-appstatus-pane=\"${p[0]}\">${p[1]}</button></li>`).join('')}</ul></div>`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`<div class=\"appstatus-pane ${i?'d-none':''}\" data-appstatus-panel=\"${p[0]}\">${p[2]}</div>`).join('')}${scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''}`;\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\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='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,pollerResponse]=await Promise.all([\n fetch('/api/app/status',{cache:'no-store'}).then(r=>r.json()),\n fetch('/api/poller/settings',{cache:'no-store'}).then(r=>r.json()).catch(()=>({ok:false}))\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 pollerBundle=(pollerResponse && pollerResponse.ok!==false) ? pollerResponse : (st.poller||{});\n const rt=pollerBundle.runtime||{}, ps=pollerBundle.settings||{};\n // Note: App status uses embedded poller data as a fallback, so one failing endpoint cannot leave Runtime poller empty.\n const intervalValue=(runtimeKey,settingsKey)=>rt[runtimeKey] ?? ps[settingsKey] ?? '-';\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 || Number(rt.last_tick_ms||0)>0);\n const waiting=!runtimeReady && rt.runtime_ready===false;\n const mode=waiting?'waiting':(rt.adaptive_mode || ((rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'fixed':'normal'));\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', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'),\n diagCard('Mode', mode),\n diagCard('Live interval', `${intervalValue('live_stats_interval_seconds','live_stats_interval_seconds')}s`),\n diagCard('List interval', `${intervalValue('torrent_list_interval_seconds','torrent_list_interval_seconds')}s`),\n diagCard('Last tick', waiting?'waiting':`${rt.duration_ms||rt.last_tick_ms||0} ms`),\n diagCard('Tick gap', waiting?'waiting':`${rt.last_tick_gap_ms||0} ms`),\n diagCard('Payload', waiting?'waiting':fmtBytes(rt.emitted_payload_size||0)),\n diagCard('rTorrent calls', waiting?'waiting':(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=`<div class=\"column-manager-tabs appstatus-tabs\"><ul class=\"nav nav-pills\">${panes.map((p,i)=>`<li class=\"nav-item\"><button class=\"nav-link ${i?'':'active'}\" type=\"button\" data-appstatus-pane=\"${p[0]}\">${p[1]}</button></li>`).join('')}</ul></div>`;\n if($('appStatusTabs')) $('appStatusTabs').innerHTML=tabs;\n box.innerHTML=`${panes.map((p,i)=>`<div class=\"appstatus-pane ${i?'d-none':''}\" data-appstatus-panel=\"${p[0]}\">${p[2]}</div>`).join('')}${scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''}`;\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n }\n\n\n\n const TORRENT_STATS_PANE_STORAGE_KEY = 'pytorrent.torrentStatsPane.v1';";
+1 -1
View File
@@ -1 +1 @@
export const diagnosticsDashboardSource = "function diagnosticsSection(title, cards){\n return `<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-stethoscope\"></i> ${esc(title)}</div><div class=\"diag-grid\">${cards.join('')}</div></section>`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status?cleanup=1').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerCards=[diagCard('Adaptive', ps.adaptive_enabled===false?'off':'on'), diagCard('Mode', rt.adaptive_mode||'-'), diagCard('Effective interval', `${rt.effective_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', `${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', `${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', rt.rtorrent_call_count||0), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-list-check\"></i> Smart Queue decisions</div>${renderSmartQueueNerdStats(smartStats)}</section>`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''].join('');\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n}\n";
export const diagnosticsDashboardSource = "function diagnosticsSection(title, cards){\n return `<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-stethoscope\"></i> ${esc(title)}</div><div class=\"diag-grid\">${cards.join('')}</div></section>`;\n}\nasync function loadDiagnosticsPage(){\n const box=$('diagnosticsPageManager');\n if(!box) return;\n box.innerHTML='<span class=\"spinner-border spinner-border-sm\"></span> Loading diagnostics...';\n try{\n const [status,poller,planner,smart]=await Promise.all([\n fetch('/api/app/status?cleanup=1').then(r=>r.json()),\n fetch('/api/poller/settings').then(r=>r.json()).catch(()=>({})),\n fetch('/api/download-planner/preview').then(r=>r.json()).catch(()=>({})),\n fetch('/api/smart-queue?history_limit=100').then(r=>r.json()).catch(()=>({ok:false})),\n ]);\n if(status && status.ok===false) throw new Error(status.error||'Failed to load diagnostics');\n const st=status.status||{}, profile=st.profile||{}, py=st.pytorrent||{}, scgi=st.scgi||{}, cleanup=st.cleanup||{}, db=cleanup.database||{}, pc=st.port_check||{};\n const rt=poller.runtime||{}, ps=poller.settings||{}, pv=planner.preview||{}, smartStats=smart?.ok?buildSmartQueueNerdStats(smart.history||[], Number(smart.history_total||0)):null;\n const profileCards=[diagCard('Active profile', profile.name||profile.id||'-'), diagCard('API response time', `${st.api_ms ?? '-'} ms`), diagCard('Incoming port', pc.port||'-'), diagCard('Port status', portStatusLabel(pc.status), pc.status==='closed'?'diag-error':'')];\n const rtCards=[diagCard('SCGI status', scgi.ok?'OK':'ERROR', scgi.ok?'':'diag-error'), diagCard('SCGI URL', scgi.url||'-'), diagCard('Connect', scgi.connect_ms!=null?`${scgi.connect_ms} ms`:'-'), diagCard('First byte', scgi.first_byte_ms!=null?`${scgi.first_byte_ms} ms`:'-'), diagCard('Total', scgi.total_ms!=null?`${scgi.total_ms} ms`:'-'), diagCard('Request bytes', scgi.request_bytes), diagCard('Response bytes', scgi.response_bytes), diagCard('XML bytes', scgi.xml_bytes), diagCard('rTorrent version', scgi.client_version||'-')];\n const pollerReady=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 pollerWaiting=!pollerReady && rt.runtime_ready===false;\n const pollerCards=[diagCard('Adaptive', (rt.adaptive_enabled ?? ps.adaptive_enabled)===false?'off':'on'), diagCard('Mode', pollerWaiting?'waiting':(rt.adaptive_mode||'-')), diagCard('Effective interval', `${rt.effective_interval_seconds??rt.live_stats_interval_seconds??ps.live_stats_interval_seconds??'-'}s`), diagCard('Minimum interval', `${rt.configured_min_interval_seconds??'-'}s`), diagCard('Tick duration', pollerWaiting?'waiting':`${rt.duration_ms||rt.last_tick_ms||0} ms`), diagCard('Tick gap', pollerWaiting?'waiting':`${rt.last_tick_gap_ms||0} ms`), diagCard('Payload', pollerWaiting?'waiting':fmtBytes(rt.emitted_payload_size||0)), diagCard('rTorrent calls', pollerWaiting?'waiting':(rt.rtorrent_call_count||0)), diagCard('Skipped emissions', rt.skipped_emissions||0), diagCard('Ticks', rt.tick_count||0)];\n const plannerCards=[diagCard('Matched rule', pv.matched_rule||'-'), diagCard('Next change', pv.next_change_at||'-'), diagCard('Planner state', pv.enabled===false?'disabled':'enabled')];\n const databaseCards=[diagCard('DB size', db.size_h||'-'), diagCard('Job logs', cleanup.jobs_clearable ?? '-'), diagCard('Smart Queue logs', cleanup.smart_queue_history_total ?? '-'), diagCard('Automation logs', cleanup.automation_history_total ?? '-')];\n const workerCards=[diagCard('Worker threads', py.worker_threads ?? '-'), diagCard('Jobs total', py.jobs_total ?? '-'), diagCard('Threads', py.threads ?? '-'), diagCard('CPU', `${py.cpu_percent ?? '-'}%`)];\n const smartBlock=`<section class=\"diagnostics-section\"><div class=\"section-title\"><i class=\"fa-solid fa-list-check\"></i> Smart Queue decisions</div>${renderSmartQueueNerdStats(smartStats)}</section>`;\n box.innerHTML=[diagnosticsSection('Profile and port',profileCards), diagnosticsSection('rTorrent connection',rtCards), diagnosticsSection('Adaptive poller',pollerCards), diagnosticsSection('Planner',plannerCards), diagnosticsSection('Database and cleanup',databaseCards), diagnosticsSection('Worker state',workerCards), smartBlock, scgi.error?`<div class=\"alert alert-danger mt-3 mb-0\">${esc(scgi.error)}</div>`:''].join('');\n }catch(e){ box.innerHTML=`<div class=\"alert alert-danger mb-0\">${esc(e.message)}</div>`; }\n}\n";
+1 -1
View File
@@ -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?` \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?` <span class=\"profile-backup-icon\" title=\"Automatic profile backup enabled\" aria-label=\"Automatic profile backup enabled\"><i class=\"fa-solid fa-floppy-disk\"></i></span>`:''; return `<div class=\"profile-row ${isActive?'active':''}\" data-profile-id=\"${esc(p.id)}\" aria-current=\"${isActive?'true':'false'}\"><b><span class=\"profile-id-badge\">#${esc(p.id)}</span> ${esc(p.name)} <span data-active-profile-badge class='badge text-bg-primary ms-1 ${isActive?'':'d-none'}'>active</span> ${p.is_remote?\"<span class='badge text-bg-secondary ms-1'>remote</span>\":''}${backupBadge} <span class=\"badge text-bg-${cls}\">${esc(st)}</span></b><span>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}</span><div class=\"profile-actions\"><button class=\"btn btn-xs btn-outline-primary\" data-use-profile=\"${p.id}\"><i class=\"fa-solid fa-plug-circle-check\"></i> use</button><button class=\"btn btn-xs btn-outline-info\" data-test-saved-profile=\"${p.id}\" title=\"Diagnostics\"><i class=\"fa-solid fa-stethoscope\"></i></button><button class=\"btn btn-xs btn-outline-secondary\" data-edit-profile=\"${p.id}\" title=\"Edit\"><i class=\"fa-solid fa-pen-to-square\"></i></button><button class=\"btn btn-xs btn-outline-danger\" data-del-profile=\"${p.id}\" title=\"Delete\"><i class=\"fa-solid fa-trash-can\"></i> Remove</button></div></div>`; }).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(){\n const j=await (await fetch('/api/profiles')).json();\n profileCache=new Map((j.profiles||[]).map(p=>[String(p.id),p]));\n const active=String(j.active?.id ?? activeProfileId ?? '');\n const rows=j.profiles||[];\n const statusMap=new Map();\n try{ const d=await (await fetch('/api/profiles/diagnostics')).json(); (d.diagnostics||[]).forEach(x=>statusMap.set(String(x.profile_id), x)); }catch(e){}\n $('profileList').innerHTML=rows.map(p=>{\n const d=statusMap.get(String(p.id))||{};\n const st=profileDiagnosticStatusLabel(d.status || 'unknown');\n const cls=profileDiagnosticStatusClass(st);\n const response=d.response_time_ms?` \u00b7 ${esc(d.response_time_ms)} ms`:'';\n const threshold=d.slow_threshold_ms?` \u00b7 slow > ${esc(d.slow_threshold_ms)} ms`:'';\n const isActive=String(p.id)===active;\n const backupIcon=p.profile_backup_enabled?`<span class=\"profile-backup-icon\" title=\"Automatic profile backup enabled\" aria-label=\"Automatic profile backup enabled\"><i class=\"fa-solid fa-floppy-disk\"></i></span>`:'';\n return `<div class=\"profile-row ${isActive?'active':''}\" data-profile-id=\"${esc(p.id)}\" aria-current=\"${isActive?'true':'false'}\"><b><span class=\"profile-id-badge\">#${esc(p.id)}</span> ${esc(p.name)} <span data-active-profile-badge class='badge text-bg-primary ms-1 ${isActive?'':'d-none'}'>active</span> ${p.is_remote?\"<span class='badge text-bg-secondary ms-1'>remote</span>\":''} <span class=\"badge text-bg-${cls}\">${esc(st)}</span></b><span>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}</span><div class=\"profile-actions\"><button class=\"btn btn-xs btn-outline-primary\" data-use-profile=\"${p.id}\"><i class=\"fa-solid fa-plug-circle-check\"></i> use</button>${backupIcon}<button class=\"btn btn-xs btn-outline-info\" data-test-saved-profile=\"${p.id}\" title=\"Diagnostics\"><i class=\"fa-solid fa-stethoscope\"></i></button><button class=\"btn btn-xs btn-outline-secondary\" data-edit-profile=\"${p.id}\" title=\"Edit\"><i class=\"fa-solid fa-pen-to-square\"></i></button><button class=\"btn btn-xs btn-outline-danger\" data-del-profile=\"${p.id}\" title=\"Delete\"><i class=\"fa-solid fa-trash-can\"></i> Remove</button></div></div>`;\n }).join('')||'No profiles.';\n }\n";
+1 -1
View File
@@ -1 +1 @@
export const ratioToolsSource = " async function loadRatios(){ const j=await (await fetch('/api/ratio-groups')).json(); const groups=j.groups||[], history=j.history||[]; if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>`<option value=\"${esc(g.name)}\">${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)})</option>`).join(''); if($('ratioManager')) $('ratioManager').innerHTML=`<h6>Groups</h6>${table(['Name','Owner','Min','Max','Seed min','Action','Move path','Set label','Enabled'],groups.map(g=>[esc(g.name),esc(g.owner_name||'-'),esc(g.min_ratio),esc(g.max_ratio),esc(g.seed_time_minutes||g.min_seed_time_minutes||0),esc(g.action),esc(g.move_path||''),esc(g.set_label||''),g.enabled?'yes':'no']))}<h6 class=\"mt-3\">Applied history</h6>${table(['Time','Torrent','Group','Action','Status','Reason'],history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]))}`; }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios); $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); }); $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); }); $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n";
export const ratioToolsSource = " async function deleteRatioGroup(groupId, groupName){\n if(!groupId) return;\n if(!confirm(`Delete ratio group \"${groupName || groupId}\"? Assigned torrents will lose only this group link.`)) return;\n try{\n await post(`/api/ratio-groups/${encodeURIComponent(groupId)}`,{},'DELETE');\n toast('Ratio group deleted','success');\n await loadRatios();\n }catch(e){ toast(e.message,'danger'); }\n }\n async function loadRatios(){\n const j=await (await fetch('/api/ratio-groups')).json();\n const groups=j.groups||[], history=j.history||[];\n if($('ratioAssignSelect')) $('ratioAssignSelect').innerHTML=groups.map(g=>`<option value=\"${esc(g.name)}\">${esc(g.name)} (${esc(g.min_ratio)}-${esc(g.max_ratio)})</option>`).join('');\n if($('ratioManager')){\n const groupRows=groups.map(g=>[\n esc(g.name),\n esc(g.owner_name||'-'),\n esc(g.min_ratio),\n esc(g.max_ratio),\n esc(g.seed_time_minutes||g.min_seed_time_minutes||0),\n esc(g.action),\n esc(g.move_path||''),\n esc(g.set_label||''),\n g.enabled?'yes':'no',\n `<button class=\"btn btn-xs btn-outline-danger ratio-group-delete\" type=\"button\" data-ratio-group-id=\"${esc(g.id)}\" data-ratio-group-name=\"${esc(g.name)}\" title=\"Delete ratio group\"><i class=\"fa-solid fa-trash-can\"></i> Delete</button>`\n ]);\n const historyRows=history.map(h=>[humanDateCell(h.created_at),esc(h.torrent_name||h.torrent_hash),esc(h.group_name||''),esc(h.action),esc(h.status),esc(h.reason||'')]);\n $('ratioManager').innerHTML=`<h6>Groups</h6>${table(['Name','Owner','Min','Max','Seed min','Action','Move path','Set label','Enabled','Delete'],groupRows)}<h6 class=\"mt-3\">Applied history</h6>${table(['Time','Torrent','Group','Action','Status','Reason'],historyRows)}`;\n }\n }\n $('labelModal')?.addEventListener('show.bs.modal',async()=>{ modalLabels=new Set(selectedHashes().flatMap(h=>labelNames(torrents.get(h)?.label))); if($('labelInput')) $('labelInput').value=''; await loadLabels(); renderLabelChooser(); });\n $('saveLabelBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } await runAction('set_label',{label:labelValue([...modalLabels])}); bootstrap.Modal.getInstance($('labelModal'))?.hide(); });\n $('addLabelToSelectionBtn')?.addEventListener('click',async()=>{ const typed=($('labelInput')?.value||'').split(/[,;|]+/).map(x=>x.trim()).filter(Boolean); for(const l of typed){ modalLabels.add(l); await saveKnownLabel(l); } if($('labelInput')) $('labelInput').value=''; renderLabelChooser(); });\n $('clearLabelsBtn')?.addEventListener('click',()=>{ modalLabels.clear(); renderLabelChooser(); });\n $('labelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-chip'); if(!chip) return; const v=chip.dataset.label||''; modalLabels.has(v)?modalLabels.delete(v):modalLabels.add(v); renderLabelChooser(); });\n $('selectedLabelList')?.addEventListener('click',e=>{ const chip=e.target.closest('.label-selected'); if(!chip) return; modalLabels.delete(chip.dataset.label||''); renderLabelChooser(); });\n $('newLabelBtn')?.addEventListener('click',async()=>{ await saveKnownLabel($('newLabelName')?.value||''); if($('newLabelName')) $('newLabelName').value=''; });\n $('ratioAssignModal')?.addEventListener('show.bs.modal',loadRatios);\n $('ratioManager')?.addEventListener('click',e=>{ const btn=e.target.closest('.ratio-group-delete'); if(btn) deleteRatioGroup(btn.dataset.ratioGroupId, btn.dataset.ratioGroupName); });\n $('applyRatioBtn')?.addEventListener('click',async()=>{ await runAction('set_ratio_group',{ratio_group:$('ratioAssignSelect').value}); bootstrap.Modal.getInstance($('ratioAssignModal'))?.hide(); });\n $('ratioSaveBtn')?.addEventListener('click',async()=>{ await post('/api/ratio-groups',{name:$('ratioName').value,min_ratio:$('ratioMin').value,max_ratio:$('ratioMax').value,seed_time_minutes:$('ratioSeed').value,action:$('ratioAction').value,move_path:$('ratioMovePath')?.value||'',set_label:$('ratioSetLabel')?.value||'',ignore_private:$('ratioIgnorePrivate')?.checked!==false,ignore_active_upload:$('ratioIgnoreUpload')?.checked!==false}); loadRatios(); });\n $('ratioCheckBtn')?.addEventListener('click',async()=>{ const j=await post('/api/ratio-groups/check',{}); toast(`Ratio applied ${j.result?.applied||0} torrent(s)`,'success'); loadRatios(); });\n";
+1 -1
View File
@@ -5891,7 +5891,7 @@ body.compact-torrent-list .mobile-progress .torrent-progress {
color: var(--bs-info);
display: inline-flex;
font-size: 0.82rem;
margin-left: 0.25rem;
margin-inline: 0.15rem 0.25rem;
vertical-align: middle;
}