This commit is contained in:
Mateusz Gruszczyński
2026-05-08 09:13:30 +02:00
parent b5f1c26a83
commit f445d25c5d
4 changed files with 165 additions and 60 deletions

View File

@@ -261,7 +261,8 @@
async function runAction(action, extra={}){ const hashes=selectedHashes(); if(!hashes.length) return toast('No torrents selected','warning'); let payload={hashes,...extra}; if(action==='move'){ openPathPicker('move'); return; } setBusy(true); try{ const j=await post(`/api/torrents/${action}`,payload); markQueuedJobs(j, hashes, action); if(action==='recheck'){ hashes.forEach(h=>{ const t=torrents.get(h); if(t) torrents.set(h,{...t,status:'Checking',hashing:1,message:'Force recheck queued'}); }); scheduleRender(true); } const parts=Number(j.bulk_parts||1); toast(parts>1?`${action} queued in ${parts} bulk parts`:`${action} queued`,'success'); if(action==='set_label') await loadLabels(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }
function flag(iso){ const code=String(iso||'').toLowerCase(); return code?`<span class="fi fi-${esc(code)}"></span> <span>${esc(code.toUpperCase())}</span>`:'-'; }
function table(headers,rows){ return `<table class="table table-sm detail-table"><thead><tr>${headers.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${r.map(c=>`<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`; }
function table(headers,rows,extraClass=''){ const cls=extraClass?` ${extraClass}`:''; return `<table class="table table-sm detail-table${cls}"><thead><tr>${headers.map(h=>`<th>${esc(h)}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${r.map(c=>`<td>${c}</td>`).join('')}</tr>`).join('')}</tbody></table>`; }
function responsiveTable(headers,rows,extraClass=''){ return `<div class="responsive-table-wrap">${table(headers,rows,extraClass)}</div>`; }
function downloadJson(filename, data){ const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const url=URL.createObjectURL(blob); const a=document.createElement('a'); a.href=url; a.download=filename; document.body.appendChild(a); a.click(); a.remove(); setTimeout(()=>URL.revokeObjectURL(url),500); }
function renderGeneral(){ const t=torrents.get(selectedHash); const labels=t?labelNames(t.label).map(l=>`<span class="chip label-mini"><i class="fa-solid fa-tag"></i> ${esc(l)}</span>`).join(' '):''; $('detailPane').innerHTML=t?`<div class="general-grid"><div><b>Name</b><span>${esc(t.name)}</span></div><div><b>Hash</b><span>${esc(t.hash)}</span></div><div><b>Path</b><span>${esc(t.path)}</span></div><div><b>Size</b><span>${esc(t.size_h)}</span></div><div><b>Progress</b><span>${esc(t.progress)}%</span></div><div><b>Ratio</b><span>${esc(t.ratio)}</span></div><div><b>Downloaded</b><span>${esc(t.down_total_h)}</span></div><div><b>Uploaded</b><span>${esc(t.up_total_h)}</span></div><div><b>Labels</b><span>${labels||'<span class="text-muted">-</span>'}</span></div><div><b>Ratio group</b><span>${esc(t.ratio_group||'')}</span></div></div>`:'Select a torrent.'; }
const FILE_PRIORITY_LABELS = {0: "Skip", 1: "Normal", 2: "High"};
@@ -377,7 +378,7 @@
if(r.summary) bits.push(esc(r.summary));
return bits.join('<br>') || '-';
};
box.innerHTML=table(
box.innerHTML=responsiveTable(
['Status','Action','Profile','Count','Details','Attempts','Started','Finished','Error','Actions'],
rows.map(r=>[
`<span class="badge text-bg-${jobStatusBadgeClass(r.status)}">${esc(r.status)}</span>`,
@@ -390,9 +391,9 @@
humanDateCell(r.finished_at),
compactCell(r.error||'',140),
jobActions(r),
])
]),
'jobs-table'
);
box.querySelector('table')?.classList.add('jobs-table');
renderJobsPager();
}
function renderJobsPager(){ const p=$('jobsPager'); if(!p)return; const pages=Math.max(1,Math.ceil(jobsTotal/jobsLimit)); p.innerHTML=`<div class="d-flex align-items-center gap-2 flex-wrap"><button class="btn btn-sm btn-outline-secondary" id="jobsPrev" ${jobsPage<=0?'disabled':''}><i class="fa-solid fa-chevron-left"></i> Prev</button><span class="small text-muted">Page ${jobsPage+1} / ${pages} · ${jobsTotal} jobs</span><button class="btn btn-sm btn-outline-secondary" id="jobsNext" ${jobsPage>=pages-1?'disabled':''}>Next <i class="fa-solid fa-chevron-right"></i></button></div>`; $('jobsPrev')?.addEventListener('click',()=>loadJobs(jobsPage-1)); $('jobsNext')?.addEventListener('click',()=>loadJobs(jobsPage+1)); }
@@ -417,7 +418,34 @@
function smartHistoryDetails(row){ try{ return typeof row.details_json==='string'?JSON.parse(row.details_json||'{}'):(row.details_json||{}); }catch(e){ return {}; } }
function smartQueueToastMessage(r){ const noEffect=r.start_no_effect?.length||0; const requested=r.start_requested?.length||0; const stopFailed=r.stop_failed?.length||0; const limit=r.max_active_downloads||r.settings?.max_active_downloads||''; const activeBefore=r.active_before; const activeAfter=r.active_after_stop ?? r.active_after_expected; const activeTail=activeBefore!==undefined?`, active ${esc(activeBefore)}->${esc(activeAfter ?? '?')}${limit?`/${esc(limit)}`:''}`:''; const cap=r.rtorrent_cap?.updated?`, cap ${r.rtorrent_cap.current}->${r.rtorrent_cap.new}`:''; const waiting=r.waiting_labeled||0; const stalled=r.stalled_labeled?.length||0; const tail=noEffect?`, no effect ${noEffect}`:requested?`, requested ${requested}`:''; const waitTail=waiting?`, waiting labeled ${waiting}`:''; const stalledTail=stalled?`, stalled ${stalled}`:''; const failTail=stopFailed?`, stop failed ${stopFailed}`:''; return `Smart Queue: stopped ${r.stopped?.length||r.paused?.length||0}, started ${r.started?.length||r.resumed?.length||0}${activeTail}${tail}${waitTail}${stalledTail}${failTail}${cap}`; }
async function loadSmartQueue(){ if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...'); if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...'); const historyLimit=smartHistoryExpanded?100:10; const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json(); if(!j.ok) return; const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[]; const totalHistory=Number(j.history_total ?? hist.length); if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled; if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5; if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300; if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024); if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1; if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0; if($('smartManager')) $('smartManager').innerHTML=ex.length?table(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),`<button class="btn btn-xs btn-outline-danger smart-unexclude" data-hash="${esc(x.torrent_hash)}"><i class="fa-solid fa-xmark"></i> remove exception</button>`])):'<div class="empty-mini"><i class="fa-solid fa-circle-info"></i> No Smart Queue exceptions. Select torrents and use <b>Exclude selected</b> to keep them outside the queue.</div>'; if($('smartHistory')) { const body=hist.length?table(['Time','Event','Checked','Active','Limit','Over','Stopped','Started','Stop failed'],hist.map(h=>{ const d=smartHistoryDetails(h); return [dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(d.active_before??'-'),esc(d.max_active_downloads??'-'),esc(d.over_limit??0),esc(h.paused_count||0),esc(h.resumed_count||0),esc((d.stop_failed||[]).length||0)]; })):'<div class="empty-mini">No Smart Queue operations yet.</div>'; const canToggle=totalHistory>10; const toggle=canToggle?`<button id="smartHistoryToggle" class="btn btn-xs btn-outline-secondary mt-2">${smartHistoryExpanded?'Show last 10':'Show more'} (${esc(totalHistory)})</button>`:''; $('smartHistory').innerHTML=`${body}${toggle}`; } }
async function loadSmartQueue(){
if($('smartManager')) $('smartManager').innerHTML=loadingMarkup('Loading Smart Queue...');
if($('smartHistory')) $('smartHistory').innerHTML=loadingMarkup('Loading Smart Queue history...');
const historyLimit=smartHistoryExpanded?100:10;
const j=await (await fetch(`/api/smart-queue?history_limit=${historyLimit}`)).json();
if(!j.ok) return;
const st=j.settings||{}, ex=j.exclusions||[], hist=j.history||[];
const totalHistory=Number(j.history_total ?? hist.length);
if($('smartEnabled')) $('smartEnabled').checked=!!st.enabled;
if($('smartMaxActive')) $('smartMaxActive').value=st.max_active_downloads||5;
if($('smartStalled')) $('smartStalled').value=st.stalled_seconds||300;
if($('smartMinSpeed')) $('smartMinSpeed').value=Math.round((st.min_speed_bytes||0)/1024);
if($('smartMinSeeds')) $('smartMinSeeds').value=st.min_seeds||1;
if($('smartMinPeers')) $('smartMinPeers').value=st.min_peers||0;
if($('smartManager')){
$('smartManager').innerHTML=ex.length
? responsiveTable(['Hash','Reason','Created','Action'],ex.map(x=>[esc(x.torrent_hash),esc(x.reason||''),dateCell(x.created_at),`<button class="btn btn-xs btn-outline-danger smart-unexclude" data-hash="${esc(x.torrent_hash)}"><i class="fa-solid fa-xmark"></i> remove exception</button>`]),'smart-exclusions-table')
: '<div class="empty-mini"><i class="fa-solid fa-circle-info"></i> No Smart Queue exceptions. Select torrents and use <b>Exclude selected</b> to keep them outside the queue.</div>';
}
if($('smartHistory')){
const body=hist.length
? responsiveTable(['Time','Event','Checked','Active','Limit','Over','Stopped','Started','Stop failed'],hist.map(h=>{ const d=smartHistoryDetails(h); return [dateCell(h.created_at),esc(h.event),esc(h.checked_count||0),esc(d.active_before??'-'),esc(d.max_active_downloads??'-'),esc(d.over_limit??0),esc(h.paused_count||0),esc(h.resumed_count||0),esc((d.stop_failed||[]).length||0)]; }),'smart-history-table')
: '<div class="empty-mini">No Smart Queue operations yet.</div>';
const canToggle=totalHistory>10;
const toggle=canToggle?`<button id="smartHistoryToggle" class="btn btn-xs btn-outline-secondary mt-2">${smartHistoryExpanded?'Show last 10':'Show more'} (${esc(totalHistory)})</button>`:'';
$('smartHistory').innerHTML=`${body}${toggle}`;
}
}
async function setSmartException(hashes, excluded, reason='manual'){ const list=[...new Set(hashes||[])].filter(Boolean); if(!list.length) return toast('No torrents selected','warning'); setBusy(true); try{ for(const h of list) await post('/api/smart-queue/exclusion',{hash:h,excluded,reason}); toast(excluded?'Smart Queue exception added':'Smart Queue exception removed','success'); await loadSmartQueue(); }catch(e){toast(e.message,'danger');} finally{setBusy(false);} }
async function saveSmartQueue(){ await post('/api/smart-queue',{enabled:$('smartEnabled')?.checked,max_active_downloads:$('smartMaxActive')?.value,stalled_seconds:$('smartStalled')?.value,min_speed_bytes:Math.round(Number($('smartMinSpeed')?.value||0)*1024),min_seeds:$('smartMinSeeds')?.value,min_peers:$('smartMinPeers')?.value}); toast('Smart Queue saved','success'); await loadSmartQueue(); }
@@ -674,8 +702,8 @@
if(!$('automationHistory')) return;
const toolbar='<div class="automation-history-toolbar"><button id="automationClearHistoryBtn" class="btn btn-xs btn-outline-danger" type="button"><i class="fa-solid fa-trash"></i> Clear history</button></div>';
const rows=hist.map(h=>[humanDateCell(h.created_at),esc(h.rule_name||''),esc(h.torrent_name||h.torrent_hash||''),automationHistoryActions(h.actions_json||'')]);
// Note: Automation history time is now human-readable and wider, while the table still wraps on small screens.
const body=hist.length?`<div class="automation-history-scroll">${table(['Time','Rule','Torrent / batch','Actions'],rows).replace('detail-table','detail-table automation-history-table')}</div>`:'<div class="empty-mini">No automation history yet.</div>';
// Note: Automation history uses the shared responsive table wrapper so it stays inside narrow mobile modals.
const body=hist.length?responsiveTable(['Time','Rule','Torrent / batch','Actions'],rows,'automation-history-table'):'<div class="empty-mini">No automation history yet.</div>';
$('automationHistory').innerHTML=toolbar+body;
}

View File

@@ -126,6 +126,11 @@ body {
color: var(--bs-body-color);
font-weight: 700;
}
.mobile-speed-stats span {
display: inline-flex;
align-items: center;
gap: 0.18rem;
}
.topbar .form-control,
.topbar .form-select {
height: 32px;
@@ -379,6 +384,28 @@ body {
.detail-table {
white-space: nowrap;
}
.responsive-table-wrap {
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--bs-border-color);
border-radius: 0.6rem;
-webkit-overflow-scrolling: touch;
}
.responsive-table-wrap .detail-table {
margin-bottom: 0;
}
.smart-exclusions-table {
min-width: 680px;
}
.smart-history-table {
min-width: 760px;
table-layout: fixed;
}
.smart-history-table th,
.smart-history-table td {
overflow-wrap: anywhere;
white-space: normal;
}
.general-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -625,18 +652,6 @@ body {
:root {
--topbar: 132px;
}
.toolbar-right {
width: 100%;
justify-content: flex-start;
flex-wrap: nowrap;
gap: 0.35rem;
}
.search {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: none;
}
.preset-grid {
grid-template-columns: 1fr 1fr;
}
@@ -1138,19 +1153,12 @@ body.mobile-mode .mobile-card {
}
}
@media (max-width: 640px) {
.toolbar-right {
flex-wrap: nowrap !important;
gap: 0.3rem !important;
}
.search {
min-width: 0 !important;
width: auto !important;
flex: 1 1 0 !important;
max-width: none !important;
}
.mobile-speed-stats {
gap: 0.25rem;
align-items: flex-start;
flex-direction: column;
gap: 0.08rem;
font-size: 0.66rem;
line-height: 1.05;
}
}
@@ -1346,6 +1354,7 @@ body.mobile-mode .mobile-card {
/* Note: Automation history has fixed compact metadata columns and a flexible Actions column, so long JSON cannot overlap Time/Rule. */
.automation-history-table {
width: 100%;
min-width: 760px;
table-layout: fixed;
white-space: normal;
}
@@ -2336,7 +2345,3 @@ body.mobile-mode .mobile-filter-bar {
white-space: normal;
}
.automation-history-scroll {
width: 100%;
overflow-x: auto;
}