diff --git a/src/stowarr/server.py b/src/stowarr/server.py index abf0e68..20de8f2 100644 --- a/src/stowarr/server.py +++ b/src/stowarr/server.py @@ -176,6 +176,8 @@ def do_GET(self): self.send_json(200, manager.store.move_queue()) elif path == "/api/reconcile-queue": self.send_json(200, manager.store.reconcile_queue()) + elif path == "/api/queue-summary": + self.send_json(200, manager.store.queue_summary()) elif path == "/api/recovery": self.send_json(200, manager.recovery_status()) elif path.startswith("/api/operations/") and path.endswith("/events"): diff --git a/src/stowarr/store.py b/src/stowarr/store.py index c421c48..5ea6691 100644 --- a/src/stowarr/store.py +++ b/src/stowarr/store.py @@ -510,6 +510,26 @@ def move_queue(self, limit: int = 200) -> list[dict]: for row in rows ] + def queue_summary(self) -> dict[str, dict[str, int]]: + terminal = ("COMPLETE", "FAILED", "CANCELLED", "INTERRUPTED") + result: dict[str, dict[str, int]] = {} + with self.lock: + for kind, table in ( + ("move", "move_queue"), + ("reconcile", "reconcile_queue"), + ): + row = self.db.execute( + f"""SELECT COUNT(*) AS total, + SUM(CASE WHEN state IN (?,?,?,?) THEN 1 ELSE 0 END) AS terminal + FROM {table}""", # nosec + terminal, + ).fetchone() + result[kind] = { + "total": int(row["total"]), + "terminal": int(row["terminal"] or 0), + } + return result + def _clear_queue(self, table: str) -> int: if table not in {"move_queue", "reconcile_queue"}: raise ValueError("Unknown queue") diff --git a/src/stowarr/web/app.js b/src/stowarr/web/app.js index 29a4e7a..5fa4faf 100644 --- a/src/stowarr/web/app.js +++ b/src/stowarr/web/app.js @@ -1,4 +1,4 @@ -const state={authenticated:false,auth:null,config:null,connections:null,serviceStatus:null,runtime:null,recovery:null,recoveryDiagnoses:new Map(),operations:[],queue:[],reconcileQueue:[],operationEvents:new Map(),selectedHistory:new Set(),securityEvents:[],sessions:[],plan:null,movePlan:null,moveTorrent:null,qbitCatalog:null,routingAudit:null,hiddenMoveColumns:new Set(),syncApp:'radarr',sync:{},safeSyncPlans:{},safeWorkflow:null,syncExpanded:new Set(),syncHiddenStatuses:{radarr:new Set(),sonarr:new Set()},operationSections:{completed:false,remaining:false},operationTracking:false,operationTrackingGeneration:0,operationHidden:false,currentOperation:null}; +const state={authenticated:false,auth:null,config:null,connections:null,serviceStatus:null,runtime:null,recovery:null,recoveryDiagnoses:new Map(),operations:[],queue:[],reconcileQueue:[],queueSummary:{move:{total:0,terminal:0},reconcile:{total:0,terminal:0}},operationEvents:new Map(),selectedHistory:new Set(),securityEvents:[],sessions:[],plan:null,movePlan:null,moveTorrent:null,qbitCatalog:null,routingAudit:null,hiddenMoveColumns:new Set(),syncApp:'radarr',sync:{},safeSyncPlans:{},safeWorkflow:null,syncExpanded:new Set(),syncHiddenStatuses:{radarr:new Set(),sonarr:new Set()},operationSections:{completed:false,remaining:false},operationTracking:false,operationTrackingGeneration:0,operationHidden:false,currentOperation:null}; const $=(s,r=document)=>r.querySelector(s);const $$=(s,r=document)=>[...r.querySelectorAll(s)]; const esc=v=>String(v??'').replace(/[&<>'"]/g,c=>({'&':'&','<':'<','>':'>',"'":''','"':'"'}[c])); const fmtTime=v=>v?new Date(v*1000).toLocaleString(): '—'; @@ -610,13 +610,17 @@ function renderQueue(){ const route=kind==='move'?`${esc(detail.current_pool||'—')} → ${esc(item.target_pool)}`:esc(detail.target_pool||'—'); return `${esc(item.public_id)}${esc(positionLabel)}${esc(detail.torrent_name||item.torrent_hash)}${esc(item.torrent_hash)}${error}${route}${badge(item.state)}${operation}${fmtTime(item.updated_at)}${action}`; }).join(''):`The ${kind==='move'?'Move':'Reconcile'} queue is empty`; - const removable=rows.filter(item=>QUEUE_TERMINAL_STATES.has(item.state)).length; + const removable=state.queueSummary?.[kind]?.terminal??rows.filter(item=>QUEUE_TERMINAL_STATES.has(item.state)).length; const clearButton=$(`.clear-queue[data-kind="${kind}"]`); if(clearButton)clearButton.disabled=!removable; }; render(state.queue||[],'move','#queue-rows'); render(state.reconcileQueue||[],'reconcile','#reconcile-queue-rows'); - const active=[...(state.queue||[]),...(state.reconcileQueue||[])].filter(item=>['QUEUED','RUNNING'].includes(item.state)).length; + const summaryKinds=['move','reconcile']; + const hasCompleteSummary=summaryKinds.every(kind=>Number.isInteger(state.queueSummary?.[kind]?.total)&&Number.isInteger(state.queueSummary?.[kind]?.terminal)); + const active=hasCompleteSummary + ?summaryKinds.reduce((count,kind)=>count+state.queueSummary[kind].total-state.queueSummary[kind].terminal,0) + :[...(state.queue||[]),...(state.reconcileQueue||[])].filter(item=>['QUEUED','RUNNING'].includes(item.state)).length; $('#queue-count').textContent=(active+(state.recovery?.count||0))||''; } @@ -713,7 +717,7 @@ async function refreshQueue(quiet=false,throwOnError=false){ if(!state.authenticated)return; const previousActiveReconcile=activeReconcileQueueSignature(); try{ - [state.queue,state.reconcileQueue,state.recovery]=await Promise.all([api('/api/queue'),api('/api/reconcile-queue'),api('/api/recovery')]); + [state.queue,state.reconcileQueue,state.queueSummary,state.recovery]=await Promise.all([api('/api/queue'),api('/api/reconcile-queue'),api('/api/queue-summary'),api('/api/recovery')]); renderQueue(); renderRecovery(); automaticallyTrackRunningQueue(); @@ -730,7 +734,7 @@ async function cancelQueue(id,kind='move'){ async function clearQueue(kind){ const label=kind==='reconcile'?'Reconcile':'Move'; const rows=kind==='reconcile'?state.reconcileQueue:state.queue; - const finished=rows.filter(item=>QUEUE_TERMINAL_STATES.has(item.state)).length; + const finished=state.queueSummary?.[kind]?.terminal??rows.filter(item=>QUEUE_TERMINAL_STATES.has(item.state)).length; if(!finished)return; const message=`This removes ${finished} finished queue ${finished===1?'entry':'entries'}. Waiting and running work and Operation History are always kept.`; if(!await confirmAction({title:`Clear finished ${label} jobs?`,message,details:[['Queue',label],['Finished entries removed',String(finished)]],confirmLabel:'Clear finished',danger:true}))return; @@ -1097,7 +1101,7 @@ async function viewSyncQueue(publicId){ } async function inspect(hash){navigate('reconcile');$('#torrent-hash').value=hash;$('#global-hash').value=hash;$('#plan-loading').classList.remove('hidden');$('#plan-result').innerHTML='';try{state.plan=await api(`/api/plan/${encodeURIComponent(hash.trim())}`);renderPlan(state.plan)}catch(e){$('#plan-result').innerHTML=`
Request failed

${esc(e.message)}

`}finally{$('#plan-loading').classList.add('hidden')}} async function refreshServiceStatus(){if(!state.authenticated)return;try{state.serviceStatus=await api('/api/status');renderServiceStatus()}catch(e){state.serviceStatus={version:state.config?.version,services:{stowarr_api:{status:'unavailable',error:e.message},qbittorrent:{status:'unavailable',error:e.message},radarr:{status:'unavailable',error:e.message},sonarr:{status:'unavailable',error:e.message}}};renderServiceStatus()}} -async function load(){try{const [config,connections,status,runtime,recovery,operations,queue,reconcileQueue,security,sessions]=await Promise.all([api('/api/config'),api('/api/settings/connections'),api('/api/status'),api('/api/settings/runtime'),api('/api/recovery'),api('/api/operations'),api('/api/queue'),api('/api/reconcile-queue'),api('/api/security/events'),api('/api/auth/sessions')]);state.config=config;state.connections=connections;state.serviceStatus=status;state.runtime=runtime;state.recovery=recovery;state.operations=operations;state.queue=queue;state.reconcileQueue=reconcileQueue;state.securityEvents=security.events;state.sessions=sessions.sessions;renderConfig();renderConnections();renderServiceStatus();renderRuntime();renderOperations();renderQueue();renderRecovery();automaticallyTrackRunningQueue();renderSecurity()}catch(e){toast(`Could not load Stowarr: ${e.message}`)}} +async function load(){try{const [config,connections,status,runtime,recovery,operations,queue,reconcileQueue,queueSummary,security,sessions]=await Promise.all([api('/api/config'),api('/api/settings/connections'),api('/api/status'),api('/api/settings/runtime'),api('/api/recovery'),api('/api/operations'),api('/api/queue'),api('/api/reconcile-queue'),api('/api/queue-summary'),api('/api/security/events'),api('/api/auth/sessions')]);state.config=config;state.connections=connections;state.serviceStatus=status;state.runtime=runtime;state.recovery=recovery;state.operations=operations;state.queue=queue;state.reconcileQueue=reconcileQueue;state.queueSummary=queueSummary;state.securityEvents=security.events;state.sessions=sessions.sessions;renderConfig();renderConnections();renderServiceStatus();renderRuntime();renderOperations();renderQueue();renderRecovery();automaticallyTrackRunningQueue();renderSecurity()}catch(e){toast(`Could not load Stowarr: ${e.message}`)}} async function revokeSessions(){if(!await confirmAction({title:'Sign out all sessions?',message:'Every active WebUI session, including this one, will be revoked.',confirmLabel:'Sign out all',danger:true}))return;try{await api('/api/auth/sessions/revoke',{method:'POST'});state.authenticated=false;state.config=null;showLogin('All WebUI sessions were signed out.')}catch(e){toast(`Sessions were not revoked: ${e.message}`)}} async function saveRuntime(event){event.preventDefault();const apply=$('#runtime-apply').checked;if(!await confirmAction({title:`${apply?'Enable':'Disable'} confirmed write operations?`,message:'Move and Reconcile still require an explicit plan-bound confirmation.',details:[['Execution mode',apply?'Write mode':'Dry run']],confirmLabel:apply?'Enable writes':'Enable dry run',danger:apply}))return;const button=event.currentTarget.querySelector('button');button.disabled=true;button.textContent='Validating mounts…';try{state.runtime=await api('/api/settings/runtime',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({apply})});state.config.apply=state.runtime.apply;renderRuntime();renderConfig();toast(`Execution mode: ${apply?'Write mode':'Dry run'}`)}catch(e){$('#runtime-apply').checked=state.runtime.apply;toast(`Execution mode was not changed: ${e.message}`)}finally{button.disabled=false;button.textContent='Save execution mode'}} async function saveConnections(event){event.preventDefault();const form=event.currentTarget;const button=$('#save-connections');const services={qbittorrent:{url:form.elements['qbittorrent-url'].value.trim(),api_key:form.elements['qbittorrent-api-key'].value.trim(),username:form.elements['qbittorrent-username'].value.trim(),password:form.elements['qbittorrent-password'].value},radarr:{url:form.elements['radarr-url'].value.trim(),api_key:form.elements['radarr-api-key'].value.trim()},sonarr:{url:form.elements['sonarr-url'].value.trim(),api_key:form.elements['sonarr-api-key'].value.trim()}};const selected=Object.entries(services).filter(([,service])=>service.url);if(!selected.length){$('#setup-error').textContent='Enter the URL and credentials for at least one service';$('#setup-error').classList.remove('hidden');return}if(!await confirmAction({title:'Test and save configured services?',message:'Existing credentials are kept for blank secret fields. Only services with a URL are tested.',details:selected.map(([name,service])=>[name==='qbittorrent'?'qBittorrent':name[0].toUpperCase()+name.slice(1),service.url]),confirmLabel:'Test and save'}))return;button.disabled=true;button.textContent='Testing connections…';$('#setup-error').classList.add('hidden');try{const result=await api('/api/settings/connections',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({services})});state.connections=result;renderConnections();form.elements['qbittorrent-api-key'].value='';form.elements['qbittorrent-password'].value='';form.elements['radarr-api-key'].value='';form.elements['sonarr-api-key'].value='';state.qbitCatalog=null;state.routingAudit=null;await refreshServiceStatus();$('#connection-setup').close();toast(`${selected.length} configured service${selected.length===1?'':'s'} tested and saved`)}catch(e){$('#setup-error').textContent=e.message;$('#setup-error').classList.remove('hidden')}finally{button.disabled=false;button.textContent='Test and save configured services'}} diff --git a/tests/test_store.py b/tests/test_store.py index 77bc518..05694ce 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -310,11 +310,41 @@ def test_reconcile_queue_cleanup_keeps_waiting_work(self): "waiting", {"auxiliaryFiles": []}, "waiting", {} ) + self.assertEqual( + store.queue_summary()["reconcile"], + {"total": 2, "terminal": 1}, + ) self.assertEqual(store.clear_reconcile_queue(), 1) self.assertEqual( [item["public_id"] for item in store.reconcile_queue()], [waiting["public_id"]], ) + self.assertEqual( + store.queue_summary()["reconcile"], + {"total": 1, "terminal": 0}, + ) + + def test_queue_summary_counts_finished_work_beyond_list_limit(self): + with tempfile.TemporaryDirectory() as directory: + store = Store(Path(directory) / "state.db") + finished = store.enqueue_move("finished", "p1", {}, "finished", {}) + store.claim_next_move() + store.finish_move(finished["id"], "COMPLETE") + for index in range(200): + store.enqueue_move( + f"waiting-{index}", "p1", {}, f"waiting-{index}", {} + ) + + visible = store.move_queue() + self.assertEqual(len(visible), 200) + self.assertFalse( + any(item["state"] in {"COMPLETE", "FAILED", "CANCELLED", "INTERRUPTED"} + for item in visible) + ) + self.assertEqual( + store.queue_summary()["move"], + {"total": 201, "terminal": 1}, + ) def test_restart_marks_queue_and_history_and_pauses_later_work(self): with tempfile.TemporaryDirectory() as directory: