From 630521778d2860429c0a595dd8b2add623163005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Sat, 13 Jun 2026 10:28:16 +0200 Subject: [PATCH] jobs logs --- pytorrent/static/js/jobTools.js | 2 +- pytorrent/static/styles.css | 25 ++++++++++++ pytorrent/templates/index.html | 18 ++++++--- scripts/mock | 71 +++++++++++++++++++++++++++++---- 4 files changed, 103 insertions(+), 13 deletions(-) diff --git a/pytorrent/static/js/jobTools.js b/pytorrent/static/js/jobTools.js index afdb8fe..fec1a69 100644 --- a/pytorrent/static/js/jobTools.js +++ b/pytorrent/static/js/jobTools.js @@ -1 +1 @@ -export const jobToolsSource = " function jobActions(r){ const id=esc(r.id); const status=String(r.status||''); const actions=[]; if(status==='failed'||status==='cancelled') actions.push(``); if(status==='pending') actions.push(``); if(status==='pending'||status==='running') actions.push(``); return actions.join(' ') || '-'; }\n function jobStatusBadgeClass(status){\n // Note: Running means active work, so it uses primary instead of danger; danger stays reserved for failed.\n const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'};\n return classes[String(status||'')] || 'warning';\n }\n async function loadJobs(page=jobsPage){\n const box=$('jobsTable');\n // Note: Finished shows only a real finished_at value; running/pending do not receive a date from updated_at.\n if(!box) return;\n jobsPage=Math.max(0,page|0);\n box.innerHTML=' Loading jobs...';\n const offset=jobsPage*jobsLimit;\n const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json();\n const rows=j.jobs||[];\n jobsTotal=Number(j.total||rows.length);\n const details=r=>{\n const count=Number(r.hash_count||0);\n if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`;\n const bits=[];\n if(count) bits.push(`${esc(count)} torrent`);\n if(r.summary) bits.push(esc(r.summary));\n return bits.join('
') || '-';\n };\n box.innerHTML=responsiveTable(\n ['Status','Source','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],\n rows.map(r=>[\n `${esc(r.status)}`,\n r.source==='automation'?` automation`:(r.is_forced?' forced':'user'),\n esc(r.action),\n esc(r.profile_id),\n esc(r.hash_count||0),\n details(r),\n esc(r.attempts||0),\n humanDateCell(r.started_at||r.created_at),\n humanDateCell(r.finished_at),\n compactCell(r.error||'',140),\n jobActions(r),\n ]),\n 'jobs-table'\n );\n renderJobsPager();\n }\n function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); }\n // Note: Job log buttons are separated so normal cleanup cannot accidentally trigger emergency cleanup.\n $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel,.job-force'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-force')){ if(!confirm('Force this pending job now in a separate worker? This can break normal queue ordering.')) return; await post(`/api/jobs/${id}/force`,{}).catch(x=>toast(x.message,'danger')); } if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); });\n $('clearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post('/api/jobs/clear',{}); toastMessage('toast.jobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n $('emergencyClearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Emergency clean ALL job logs, including unfinished jobs?')) return; try{ const j=await post('/api/jobs/clear?force=1',{}); toastMessage('toast.emergencyJobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n\n"; +export const jobToolsSource = " const JOB_DETAILS_STORAGE_KEY = 'pytorrent.jobs.showDetails.v1';\n\n function readJobDetailsPreference(){\n try{ return localStorage.getItem(JOB_DETAILS_STORAGE_KEY) === '1'; }\n catch(e){ return false; }\n }\n\n function saveJobDetailsPreference(showDetails){\n try{ localStorage.setItem(JOB_DETAILS_STORAGE_KEY, showDetails ? '1' : '0'); }\n catch(e){}\n }\n\n function syncJobDetailsControl(){\n if($('jobsShowDetails')) $('jobsShowDetails').checked = readJobDetailsPreference();\n }\n\n function jobActions(r){ const id=esc(r.id); const status=String(r.status||''); const actions=[]; if(status==='failed'||status==='cancelled') actions.push(``); if(status==='pending') actions.push(``); if(status==='pending'||status==='running') actions.push(``); return actions.join(' ') || '-'; }\n function jobStatusBadgeClass(status){\n // Note: Running means active work, so it uses primary instead of danger; danger stays reserved for failed.\n const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'};\n return classes[String(status||'')] || 'warning';\n }\n async function loadJobs(page=jobsPage){\n const box=$('jobsTable');\n // Note: Finished shows only a real finished_at value; running/pending do not receive a date from updated_at.\n if(!box) return;\n jobsPage=Math.max(0,page|0);\n syncJobDetailsControl();\n box.innerHTML=' Loading jobs...';\n const offset=jobsPage*jobsLimit;\n const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`, {cache:'no-store'})).json();\n const rows=j.jobs||[];\n jobsTotal=Number(j.total||rows.length);\n const showDetails = readJobDetailsPreference();\n const details=r=>{\n const count=Number(r.hash_count||0);\n if(!showDetails) return 'Details hidden';\n const bits=[];\n if(r.is_bulk || count>1) bits.push('bulk');\n if(count) bits.push(`${esc(count)} torrent${count === 1 ? '' : 's'}`);\n if(r.summary) bits.push(esc(r.summary));\n return bits.join('
') || '-';\n };\n box.innerHTML=responsiveTable(\n ['Status','Source','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],\n rows.map(r=>[\n `${esc(r.status)}`,\n r.source==='automation'?` automation`:(r.is_forced?' forced':'user'),\n esc(r.action),\n esc(r.profile_id),\n esc(r.hash_count||0),\n details(r),\n esc(r.attempts||0),\n humanDateCell(r.started_at||r.created_at),\n humanDateCell(r.finished_at),\n compactCell(r.error||'',140),\n jobActions(r),\n ]),\n 'jobs-table'\n );\n renderJobsPager();\n }\n function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`
Page ${jobsPage+1} / ${pages} ${jobsTotal} jobs
`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); }\n // Note: Job details now use the same opt-in visibility pattern as Logs, so large/bulk rows stay compact by default.\n $('jobsModal')?.addEventListener('show.bs.modal',()=>{ syncJobDetailsControl(); loadJobs(); }); $('refreshJobsBtn')?.addEventListener('click',()=>loadJobs()); $('jobsShowDetails')?.addEventListener('change',()=>{ saveJobDetailsPreference($('jobsShowDetails')?.checked === true); loadJobs(jobsPage); }); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel,.job-force'); if(!btn)return; const id=btn.dataset.id; if(!id)return; if(btn.classList.contains('job-retry')) await post(`/api/jobs/${id}/retry`,{}).catch(x=>toast(x.message,'danger')); if(btn.classList.contains('job-force')){ if(!confirm('Force this pending job now in a separate worker? This can break normal queue ordering.')) return; await post(`/api/jobs/${id}/force`,{}).catch(x=>toast(x.message,'danger')); } if(btn.classList.contains('job-cancel')){ const st=btn.dataset.status||''; if((st==='pending'||st==='running') && !confirm('Emergency cancel this unfinished job?')) return; await post(`/api/jobs/${id}/cancel`,{}).catch(x=>toast(x.message,'danger')); } loadJobs(); });\n $('clearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Clear finished job logs? Pending and running jobs will stay.')) return; try{ const j=await post('/api/jobs/clear',{}); toastMessage('toast.jobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n $('emergencyClearJobsBtn')?.addEventListener('click',async()=>{ if(!confirm('Emergency clean ALL job logs, including unfinished jobs?')) return; try{ const j=await post('/api/jobs/clear?force=1',{}); toastMessage('toast.emergencyJobLogsCleared','success',{deleted:j.deleted}); jobsPage=0; loadJobs(0); }catch(e){ toast(e.message,'danger'); } });\n\n"; diff --git a/pytorrent/static/styles.css b/pytorrent/static/styles.css index f241d86..22b17b9 100644 --- a/pytorrent/static/styles.css +++ b/pytorrent/static/styles.css @@ -3069,6 +3069,31 @@ body.mobile-mode .mobile-filter-bar { outline: 0; } + +.jobs-toolbar, +.jobs-toolbar-actions, +.jobs-toolbar-toggle { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +.jobs-toolbar { + align-items: center; + justify-content: space-between; + margin-bottom: 0.5rem; +} + +.jobs-toolbar-actions, +.jobs-toolbar-toggle { + align-items: center; +} + +.jobs-show-details { + align-items: center; + margin-bottom: 0; +} + .jobs-table { min-width: 1080px; white-space: normal; diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index d533b9d..d134c99 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -190,11 +190,19 @@