From 0dcdf0e22bc1d86b501ab79bc063a591a8f52cfe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Mon, 4 May 2026 22:57:39 +0200 Subject: [PATCH] filters and jobs finished date --- pytorrent/static/app.js | 64 ++++++++++++++++++++++++++++++---- pytorrent/templates/index.html | 1 + 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/pytorrent/static/app.js b/pytorrent/static/app.js index 06a6523..65b78ac 100644 --- a/pytorrent/static/app.js +++ b/pytorrent/static/app.js @@ -69,7 +69,7 @@ function progressBar(value, extraClass=''){ const pct=Math.max(0,Math.min(100,Number(value||0))); const hue=Math.round((pct/100)*120); const light=30+Math.round((pct/100)*5); const bg=pct<=0?'transparent':pct>=100?'var(--torrent-progress-complete)':`hsl(${hue} 52% ${light}%)`; const done=pct>=100?' is-complete':''; const cls=extraClass?` ${extraClass}`:''; return `
${esc(pct)}%
`; } function progress(t){ return progressBar(t.progress); } // Note: Displays status filter summaries calculated and cached by the backend API. - const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', stopped:'countStopped'}; + const FILTER_COUNT_IDS = {all:'countAll', downloading:'countDownloading', seeding:'countSeeding', paused:'countPaused', checking:'countChecking', error:'countError', stopped:'countStopped', moving:'countMoving'}; function formatFilterBytes(value){ return fmtBytes(value).replace(/\.0 (?=GiB|TiB)/, ' '); } function filterMetaLine(bucket){ if(!bucket || !Number(bucket.count||0)) return ''; @@ -137,16 +137,25 @@ } applyFilterTooltip(button, tooltip, ariaLabel); } + function movingOperationRows(){ + // Note: Filtr Moving bazuje tylko na trwajacych operacjach move, a nie na oczekujacych zadaniach. + return [...torrents.values()].filter(t=>{ + const op=activeOperationFor(t); + return op?.action==='move' && op?.state==='running'; + }); + } + function movingFilterCount(){ return movingOperationRows().length; } function setFilterSummary(type){ const el=$(FILTER_COUNT_IDS[type]); if(!el) return; - const bucket=torrentSummary?.filters?.[type] || {count:0}; - const meta=filterMetaLine(bucket, type); - const tooltip=filterTooltipLine(bucket, type); + const bucket=type==='moving' ? {count:movingFilterCount()} : (torrentSummary?.filters?.[type] || {count:0}); + const meta=type==='moving' ? '' : filterMetaLine(bucket, type); + const tooltip=type==='moving' && bucket.count ? 'Active moving operations' : filterTooltipLine(bucket, type); el.innerHTML=`${esc(bucket.count||0)}${meta?`${esc(meta)}`:''}`; const button=el.closest('.filter'); if(button){ const ariaLabel = tooltip ? `${button.dataset.filter || type}: ${tooltip.replace(/\n/g, ', ')}` : ''; + button.classList.toggle('d-none', type==='moving' && !Number(bucket.count||0)); setStableFilterTooltip(button, tooltip, ariaLabel); } } @@ -155,12 +164,19 @@ function rowHasLabel(t,label){ return labelNames(t.label).includes(label); } function torrentHasError(t){ return !!torrentWarning(t); } function isChecking(t){ return t?.status==='Checking' || Number(t?.hashing||0)>0; } - function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && ![t.name,t.path,t.label,t.hash,t.ratio_group].join(' ').toLowerCase().includes(q)) return false; if(activeFilter==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused; if(activeFilter==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused; if(activeFilter==='paused') return !!t.paused || t.status==='Paused'; if(activeFilter==='checking') return isChecking(t); if(activeFilter==='error') return torrentHasError(t); if(activeFilter==='stopped') return !t.state && !isChecking(t); if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); return true; } + function rowVisible(t){ const q=($('searchBox')?.value||'').toLowerCase(); if(q && ![t.name,t.path,t.label,t.hash,t.ratio_group].join(' ').toLowerCase().includes(q)) return false; if(activeFilter==='downloading') return !isChecking(t) && !t.complete && t.state && !t.paused; if(activeFilter==='seeding') return !isChecking(t) && t.complete && t.state && !t.paused; if(activeFilter==='paused') return !!t.paused || t.status==='Paused'; if(activeFilter==='checking') return isChecking(t); if(activeFilter==='error') return torrentHasError(t); if(activeFilter==='stopped') return !t.state && !isChecking(t); if(activeFilter==='moving') { const op=activeOperationFor(t); return op?.action==='move' && op?.state==='running'; } if(activeFilter.startsWith('label:')) return rowHasLabel(t,activeFilter.slice(6)); return true; } function compareRows(a,b){ const k=sortState.key; let av=a[k], bv=b[k]; if(typeof av==='string'||typeof bv==='string') return String(av||'').localeCompare(String(bv||''))*sortState.dir; return ((Number(av||0)>Number(bv||0))?1:(Number(av||0)0?" ":" "; } function updateSortHeaders(){ document.querySelectorAll('.torrent-table thead th[data-sort]').forEach(th=>{ const base=th.dataset.baseText||th.textContent.trim(); th.dataset.baseText=base; th.innerHTML=`${esc(base)}${sortIcon(th.dataset.sort)}`; th.classList.toggle('sorted',sortState.key===th.dataset.sort); }); } // Note: Refreshes sidebar counters from the cached API summary, not from browser-side aggregation. + function syncFilterButtons(){ + // Note: Klasa active jest synchronizowana po automatycznym powrocie z Moving do All. + document.querySelectorAll('.filter').forEach(x=>x.classList.toggle('active', x.dataset.filter===activeFilter)); + } function renderCounts(){ + // Note: Gdy ostatnia operacja move sie skonczy, ukryty filtr nie zostawia pustej listy jako aktywnej. + if(activeFilter==='moving' && !movingFilterCount()) activeFilter='all'; + syncFilterButtons(); Object.keys(FILTER_COUNT_IDS).forEach(setFilterSummary); $('statSelected').textContent=selected.size; } @@ -206,7 +222,7 @@ function torrentWarning(t){ const msg=String(t.message||'').trim(); if(!msg) return null; const l=msg.toLowerCase(); const patterns=['error','failed','failure','timeout','timed out','tracker','could not','cannot','refused','unreachable','denied']; return patterns.some(p=>l.includes(p)) ? msg : null; } function torrentNameIcon(t){ const m=statusMeta(t); return ``; } function renderRow(t){ const labels=labelNames(t.label).map(l=>` ${esc(l)}`).join(' '); const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', t.paused?'torrent-paused':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); const title=[t.name,warn,op?op.label:''].filter(Boolean).join('\n'); return `${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}${statusBadge(t)}${esc(t.size_h)}${progress(t)}${esc(t.down_rate_h)}${esc(t.up_rate_h)}${esc(t.seeds)}${esc(t.peers)}${esc(t.ratio)}${esc(t.path)}${labels||'-'}${esc(t.ratio_group||'')}`; } - function mobileFilterDefs(){ const arr=[...torrents.values()]; const f=torrentSummary?.filters||{}; const defs=[['all','All',f.all?.count??0],['downloading','Downloading',f.downloading?.count??0],['seeding','Seeding',f.seeding?.count??0],['paused','Paused',f.paused?.count??0],['checking','Checking',f.checking?.count??0],['error','With error',f.error?.count??0],['stopped','Stopped',f.stopped?.count??0]]; const counts=new Map(); arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1))); [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label'])); return defs; } + function mobileFilterDefs(){ const arr=[...torrents.values()]; const f=torrentSummary?.filters||{}; const defs=[['all','All',f.all?.count??0],['downloading','Downloading',f.downloading?.count??0],['seeding','Seeding',f.seeding?.count??0],['paused','Paused',f.paused?.count??0],['checking','Checking',f.checking?.count??0],['error','With error',f.error?.count??0],['stopped','Stopped',f.stopped?.count??0]]; const movingCount=movingFilterCount(); if(movingCount) defs.push(['moving','Moving',movingCount]); const counts=new Map(); arr.forEach(t=>labelNames(t.label).forEach(l=>counts.set(l,(counts.get(l)||0)+1))); [...counts.keys()].sort((a,b)=>a.localeCompare(b)).forEach(l=>defs.push([`label:${l}`,l,counts.get(l),'label'])); return defs; } function renderMobileFilters(){ const bar=$('mobileFilterBar'); if(!bar) return; const allVisible=visibleRows.length>0 && visibleRows.every(t=>selected.has(t.hash)); const someVisible=visibleRows.some(t=>selected.has(t.hash)); const opts=mobileFilterDefs().map(([key,label,count,type])=>``).join(''); bar.innerHTML=`
${selected.size} selected
`; } function renderMobile(){ const list=$('mobileList'); if(!list) return; const src=visibleRows.length?visibleRows:[...torrents.values()].filter(rowVisible).sort(compareRows); const rows=src.slice(0,250); renderMobileFilters(); list.innerHTML=rows.map(t=>{ const warn=torrentWarning(t); const op=activeOperationFor(t); const classes=[selected.has(t.hash)?'selected':'', op?'torrent-operating':'', warn?'torrent-warning':''].filter(Boolean).join(' '); return `
${warn?' ':''}${torrentNameIcon(t)} ${esc(t.name)}
${statusBadge(t)} · ${esc(t.progress)}% · Ratio ${esc(t.ratio)}
DL ${esc(t.down_rate_h)} / UL ${esc(t.up_rate_h)}
${esc(t.path)}
${progress(t)}
`; }).join('') || (hasTorrentSnapshot ? `
No torrents.
` : loadingMarkup('Loading torrents...')); } function renderTable(){ updateBulkBar(); renderCounts(); renderLabelFilters(); updateSortHeaders(); buildVisibleRows(); renderMobile(); const body=$('torrentBody'); if(!visibleRows.length){ body.innerHTML=hasTorrentSnapshot?'No torrents for this filter.':loadingTableRow('Loading torrents...'); return; } const wrap=$('tableWrap'); const start=Math.max(0,Math.floor((wrap?.scrollTop||0)/ROW_HEIGHT)-OVERSCAN); const count=Math.ceil((wrap?.clientHeight||500)/ROW_HEIGHT)+OVERSCAN*2; const end=Math.min(visibleRows.length,start+count); const sig=`${renderVersion}:${start}:${end}:${visibleRows.length}:${sortState.key}:${sortState.dir}:${selected.size}:${activeFilter}:${$('searchBox')?.value||''}:${[...selected].slice(0,30).join(',')}`; if(sig===lastRenderSignature) return; lastRenderSignature=sig; const top=start*ROW_HEIGHT,bottom=Math.max(0,(visibleRows.length-end)*ROW_HEIGHT); body.innerHTML=(top?``:'')+visibleRows.slice(start,end).map(renderRow).join('')+(bottom?``:''); applyColumnVisibility(); } @@ -323,7 +339,41 @@ const classes={done:'success',failed:'danger',running:'primary',cancelled:'secondary',pending:'warning'}; return classes[String(status||'')] || 'warning'; } - async function loadJobs(page=jobsPage){ const box=$('jobsTable'); if(!box)return; jobsPage=Math.max(0,page|0); box.innerHTML=' Loading jobs...'; const offset=jobsPage*jobsLimit; const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json(); const rows=j.jobs||[]; jobsTotal=Number(j.total||rows.length); const details=r=>{ const count=Number(r.hash_count||0); if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`; const bits=[]; if(count) bits.push(`${esc(count)} torrent`); if(r.summary) bits.push(esc(r.summary)); return bits.join('
') || '-'; }; box.innerHTML=table(['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],rows.map(r=>[`${esc(r.status)}`,esc(r.action),esc(r.profile_id),esc(r.hash_count||0),details(r),esc(r.attempts||0),dateCell(r.started_at||r.created_at),dateCell(r.finished_at||r.updated_at),compactCell(r.error||'',140),jobActions(r)])); renderJobsPager(); } + async function loadJobs(page=jobsPage){ + const box=$('jobsTable'); + // Note: Finished pokazuje tylko realne finished_at; running/pending nie dostaja daty z updated_at. + if(!box) return; + jobsPage=Math.max(0,page|0); + box.innerHTML=' Loading jobs...'; + const offset=jobsPage*jobsLimit; + const j=await (await fetch(`/api/jobs?limit=${jobsLimit}&offset=${offset}`)).json(); + const rows=j.jobs||[]; + jobsTotal=Number(j.total||rows.length); + const details=r=>{ + const count=Number(r.hash_count||0); + if(r.is_bulk || count>1) return `bulk
${esc(count)} torrent(s), details hidden`; + const bits=[]; + if(count) bits.push(`${esc(count)} torrent`); + if(r.summary) bits.push(esc(r.summary)); + return bits.join('
') || '-'; + }; + box.innerHTML=table( + ['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'], + rows.map(r=>[ + `${esc(r.status)}`, + esc(r.action), + esc(r.profile_id), + esc(r.hash_count||0), + details(r), + esc(r.attempts||0), + dateCell(r.started_at||r.created_at), + dateCell(r.finished_at), + compactCell(r.error||'',140), + jobActions(r), + ]) + ); + renderJobsPager(); + } 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)); } // Note: Przyciski w job logu sa zalezne od statusu: failed ma retry, a emergency cancel tylko pending/running. $('jobsModal')?.addEventListener('show.bs.modal',loadJobs); $('refreshJobsBtn')?.addEventListener('click',loadJobs); $('jobsTable')?.addEventListener('click',async e=>{ const btn=e.target.closest('.job-retry,.job-cancel'); 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-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(); }); diff --git a/pytorrent/templates/index.html b/pytorrent/templates/index.html index acb00c3..ab36752 100644 --- a/pytorrent/templates/index.html +++ b/pytorrent/templates/index.html @@ -54,6 +54,7 @@ +

Shortcuts