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 `
`; }
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