Files
importarr/importarr/templates/index.html
T
2026-07-29 20:28:09 +02:00

133 lines
13 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>📥 - idle · Importarr {{ status.build.version }}</title>
<link rel="stylesheet" href="/static/importarr.css">
</head>
<body>
<header class="topbar">
<div class="brand"><h1>Importarr</h1><p id="ready-state">{{ 'Running' if status.control.queue_mode == 'start' else status.control.queue_mode|capitalize }}</p></div>
<div class="top-status"><span>Current</span><strong id="top-current-job">{{ status.current or 'idle' }}</strong></div>
<div class="top-controls">
<button type="button" data-control="start" aria-label="Start imports"></button>
<button type="button" data-control="pause" aria-label="Pause imports"></button>
<details class="menu">
<summary aria-label="Open menu"></summary>
<div class="menu-panel">
<button type="button" id="open-settings">Settings</button>
<button type="button" data-control="stop">Stop queue</button>
<button type="button" data-control="cancel-current" class="danger">Cancel current job</button>
<button id="force-run" type="button">Force run now</button>
<a href="#manual-batches">Manual batches</a>
<a href="#service-info">Service info</a>
</div>
</details>
</div>
</header>
<main>
<section class="summary-strip" aria-label="Importarr summary">
<article><strong>{{ status.control.queue_mode }}</strong><span>Queue mode</span></article>
<article><strong>{{ status.current or 'Idle' }}</strong><span>Current import</span></article>
<article><strong>{{ status.imported_total }}</strong><span>Imported</span></article>
<article><strong>{{ status.failed_total }}</strong><span>Failed</span></article>
</section>
<details class="panel packed" id="service-info">
<summary>Service info and build details</summary>
<dl class="info">
<dt>Name</dt><dd>{{ status.build.name }}</dd>
<dt>Version</dt><dd>{{ status.build.version }}</dd>
<dt>Build date</dt><dd>{{ status.build.build_date }}</dd>
<dt>Git SHA</dt><dd>{{ status.build.git_sha }}</dd>
<dt>Started</dt><dd>{{ status.build.started_at }}</dd>
<dt>Python</dt><dd>{{ status.build.python }}</dd>
<dt>SAB URL</dt><dd>{{ status.sab_url }}</dd>
<dt>SAB API token</dt><dd id="sab-token-status">{{ 'configured' if status.sab_api_key_configured else 'not configured' }}</dd>
<dt>Radarr</dt><dd>{{ status.radarr_url or 'not configured' }}</dd>
<dt>Sonarr</dt><dd>{{ status.sonarr_url or 'not configured' }}</dd>
<dt>Download root</dt><dd>{{ status.download_root }}</dd>
<dt>Movies root</dt><dd>{{ status.movies_root }}</dd>
<dt>TV root</dt><dd>{{ status.tv_root }}</dd>
<dt>Write auth</dt><dd>{{ 'enabled' if status.auth_enabled else 'disabled' }}</dd>
<dt>Queue mode</dt><dd id="queue-mode">{{ status.control.queue_mode }}</dd>
<dt>Current job</dt><dd id="current-job">{{ status.current or 'idle' }}</dd>
</dl>
</details>
<details class="panel packed">
<summary>Queue controls</summary>
<p>Pause and stop prevent new jobs from being added to the queue. They do not interrupt an import already in progress; use cancel current job for that.</p>
<div class="controls">
<button type="button" data-control="start">Start</button>
<button type="button" data-control="pause">Pause</button>
<button type="button" data-control="stop">Stop</button>
<button type="button" data-control="cancel-current" class="danger">Cancel current job</button>
</div>
</details>
<details class="panel packed" id="manual-batches">
<summary>Manual batches</summary>
<form id="batch-form" class="inline-form">
<input name="path" placeholder="folder under download root">
<input id="batch-picker" type="file" webkitdirectory directory multiple hidden>
<button type="button" id="browse-batch">Browse…</button>
<button>Add batch</button>
</form>
<div class="table-scroll"><table><thead><tr><th>ID</th><th>Status</th><th>Path</th></tr></thead><tbody>
{% for batch in batches %}<tr><td>{{ batch.id }}</td><td>{{ batch.status }}</td><td>{{ batch.path }}</td></tr>{% endfor %}
</tbody></table></div>
</details>
<section class="panel queue-panel">
<div class="section-title"><h2>Queue and history</h2><span>Grouped by processing state</span></div>
<div id="jobs">Loading…</div>
</section>
</main>
<dialog id="settings-dialog">
<form id="settings-form" method="dialog">
<div class="section-title"><h2>Settings</h2><button type="button" id="close-settings">Close</button></div>
<fieldset>
<legend>SABnzbd</legend>
<label>SAB URL <input name="sab_url" type="url" value="{{ status.sab_url }}" placeholder="http://sabnzbd:8080" required></label>
<label>API token <input name="sab_api_key" type="password" placeholder="{% if status.sab_api_key_configured %}Configured; enter a new token to replace{% else %}SAB API token{% endif %}" autocomplete="off"></label>
<button type="button" data-test-connection="sabnzbd">Test SABnzbd connection</button><output id="sabnzbd-test-result"></output>
</fieldset>
<fieldset>
<legend>Radarr</legend>
<label>Radarr URL <input name="radarr_url" type="url" value="{{ status.radarr_url }}" placeholder="http://radarr:7878"></label>
<label>API token <input name="radarr_api_key" type="password" placeholder="{% if status.radarr_api_key_configured %}Configured; enter a new token to replace{% else %}Radarr API token{% endif %}" autocomplete="off"></label>
<button type="button" data-test-connection="radarr">Test Radarr connection</button><output id="radarr-test-result"></output>
</fieldset>
<fieldset>
<legend>Sonarr</legend>
<label>Sonarr URL <input name="sonarr_url" type="url" value="{{ status.sonarr_url }}" placeholder="http://sonarr:8989"></label>
<label>API token <input name="sonarr_api_key" type="password" placeholder="{% if status.sonarr_api_key_configured %}Configured; enter a new token to replace{% else %}Sonarr API token{% endif %}" autocomplete="off"></label>
<button type="button" data-test-connection="sonarr">Test Sonarr connection</button><output id="sonarr-test-result"></output>
</fieldset>
<p class="hint">Blank token fields clear the stored token. Environment values remain the startup defaults until saved here.</p>
<button type="submit">Save settings</button>
</form>
</dialog>
<script>
const esc=value=>String(value??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch]));
async function postJson(url, body){ const response=await fetch(url,{method:'POST',headers:{'content-type':'application/json'},body:body?JSON.stringify(body):undefined}); if(!response.ok){ const error=await response.json().catch(()=>({detail:response.statusText})); alert(error.detail||'Request failed'); } return response; }
function actionButtons(j){ const buttons=[]; if(j.can_run_now) buttons.push(`<button type="button" data-action="run-now" data-id="${j.id}" title="Run now">▶</button>`); if(j.can_retry) buttons.push(`<button type="button" data-action="retry" data-id="${j.id}" title="Retry">↻</button>`); if(j.can_ignore) buttons.push(`<button type="button" data-action="ignore" data-id="${j.id}" class="warn" title="Ignore">!</button>`); if(j.can_remove) buttons.push(`<button type="button" data-action="remove" data-id="${j.id}" class="danger" title="Remove">🗑</button>`); return buttons.join(' '); }
function jobSubtext(j){ return `${esc(j.source_type)}${j.batch_id?' · batch '+esc(j.batch_id):''} · ${esc(j.relative_path||j.storage||j.source_id)}`; }
function readiness(j){ return `<span class="state" title="${esc(j.reason||j.state)}">${esc(j.state)}</span>`; }
function renderJobs(data){ const groups=(data.groups||[]).filter(group=>group.jobs.length); if(!groups.length) return '<p>No queue items.</p>'; return groups.map(group=>`<section class="job-group"><h3>${esc(group.label)} <span>${group.jobs.length}</span></h3><div class="table-scroll jobs-table"><table><thead><tr><th>File</th><th>Readiness</th><th>SAB</th><th>Actions</th></tr></thead><tbody>${group.jobs.map(j=>`<tr><td><strong class="file-name">${esc(j.name)}</strong><small>${jobSubtext(j)}</small></td><td>${readiness(j)}</td><td><small>${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}</small></td><td class="row-actions">${actionButtons(j)}</td></tr>`).join('')}</tbody></table></div><div class="job-cards">${group.jobs.map(j=>`<article class="job-card"><strong class="file-name">${esc(j.name)}</strong><small>${jobSubtext(j)}</small><dl><dt>Readiness</dt><dd>${readiness(j)}</dd><dt>SAB</dt><dd>${esc(j.sab_status||'—')}${j.sab_category?' · '+esc(j.sab_category):''}</dd></dl><div class="row-actions">${actionButtons(j)}</div></article>`).join('')}</div></section>`).join(''); }
async function refresh(){ const r=await fetch('/api/jobs'); const d=await r.json(); document.title=d.jobs.length?`📥 ${d.jobs.length} jobs - Importarr`:'📥 - idle · Importarr'; document.getElementById('jobs').innerHTML=renderJobs(d); if(d.control){ const current=d.control.current||'idle'; document.getElementById('queue-mode').textContent=d.control.queue_mode; document.getElementById('current-job').textContent=current; document.getElementById('top-current-job').textContent=current; document.getElementById('ready-state').textContent=d.control.queue_mode==='start'?'Running':d.control.queue_mode; } }
document.getElementById('jobs').addEventListener('click', async e=>{ const button=e.target.closest('button[data-action]'); if(!button)return; const action=button.dataset.action; const destructive=['ignore','remove'].includes(action); if(destructive&&!confirm(`${action} this Importarr queue item?`)) return; await postJson(`/api/queue-items/${button.dataset.id}/action`,{action}); await refresh(); });
document.querySelectorAll('[data-control]').forEach(button=>button.addEventListener('click', async()=>{ if(button.dataset.control==='cancel-current'&&!confirm('Cancel the current import job?')) return; await postJson(`/api/control/${button.dataset.control}`); await refresh(); }));
document.getElementById('browse-batch').addEventListener('click',()=>document.getElementById('batch-picker').click());
document.getElementById('batch-picker').addEventListener('change',e=>{ const f=e.target.files[0]; if(!f)return; const top=(f.webkitRelativePath||'').split('/')[0]; if(top) document.querySelector('#batch-form [name="path"]').value=top; });
document.getElementById('batch-form').addEventListener('submit', async e=>{ e.preventDefault(); const response=await postJson('/api/manual-batches',{path:e.target.path.value}); if(response.ok) location.reload(); });
document.getElementById('open-settings').addEventListener('click',()=>document.getElementById('settings-dialog').showModal());
document.getElementById('close-settings').addEventListener('click',()=>document.getElementById('settings-dialog').close());
document.addEventListener('click',e=>{ document.querySelectorAll('details.menu[open]').forEach(menu=>{ if(!menu.contains(e.target)) menu.removeAttribute('open'); }); });
document.getElementById('settings-dialog').addEventListener('click',e=>{ if(e.target===e.currentTarget) e.currentTarget.close(); });
document.getElementById('settings-form').addEventListener('submit', async e=>{ e.preventDefault(); const body=Object.fromEntries(new FormData(e.target)); const response=await postJson('/api/settings',body); if(response.ok){ const data=await response.json(); document.getElementById('sab-token-status').textContent=data.sab_api_key_configured?'configured':'not configured'; ['sab_api_key','radarr_api_key','sonarr_api_key'].forEach(name=>e.target.elements[name].value=''); document.getElementById('settings-dialog').close(); } });
document.querySelectorAll('[data-test-connection]').forEach(button=>button.addEventListener('click', async()=>{ const form=document.getElementById('settings-form'); const service=button.dataset.testConnection; const prefix=service==='sabnzbd'?'sab':service; const output=document.getElementById(`${service}-test-result`); output.textContent='Testing…'; output.className=''; const response=await postJson('/api/settings/test-connection',{service,url:form.elements[`${prefix}_url`].value,api_key:form.elements[`${prefix}_api_key`].value}); if(response.ok){ const data=await response.json(); output.textContent=data.message; output.className=data.ok?'success':'error'; } }));
document.getElementById('force-run').addEventListener('click', async()=>{ await postJson('/api/import/run-now',{force:true}); await refresh(); });
refresh(); setInterval(refresh, 10000);
</script>
</body>
</html>