Show self-update availability in UI #25

This commit is contained in:
2026-07-29 21:21:05 +02:00
parent 006db01930
commit 7be54f9f3c
3 changed files with 312 additions and 21 deletions
File diff suppressed because one or more lines are too long
+9 -1
View File
@@ -10,6 +10,11 @@
<header class="topbar"> <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="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-status"><span>Current</span><strong id="top-current-job">{{ status.current or 'idle' }}</strong></div>
<section id="update-banner" class="update-banner" hidden aria-live="polite">
<strong id="update-message">New version available</strong>
<button type="button" id="update-now">Update now</button>
<code id="update-command">{{ status.build.version }}</code>
</section>
<div class="top-controls"> <div class="top-controls">
<button type="button" data-control="start" aria-label="Start imports"></button> <button type="button" data-control="start" aria-label="Start imports"></button>
<button type="button" data-control="pause" aria-label="Pause imports"></button> <button type="button" data-control="pause" aria-label="Pause imports"></button>
@@ -123,6 +128,8 @@
<script> <script>
const esc=value=>String(value??'').replace(/[&<>"']/g,ch=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[ch])); 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; } 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 renderUpdateCheck(update){ const banner=document.getElementById('update-banner'); if(!update?.update_available){ banner.hidden=true; return; } document.getElementById('update-message').textContent=`↑ New version available: ${update.latest_version}`; document.getElementById('update-command').textContent=`current ${update.current_version}`; banner.hidden=false; }
async function checkForUpdates(){ const response=await fetch('/api/control/update-check'); if(!response.ok) return; renderUpdateCheck(await response.json()); }
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 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 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 readiness(j){ return `<span class="state" title="${esc(j.reason||j.state)}">${esc(j.state)}</span>`; }
@@ -145,7 +152,8 @@
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.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'; } }));
const themes=['auto','light','dark']; function applyTheme(theme){ document.documentElement.dataset.theme=theme; localStorage.setItem('importarr-theme',theme); document.getElementById('theme-toggle').textContent=`Theme: ${theme}`; } document.getElementById('theme-toggle').addEventListener('click',()=>applyTheme(themes[(themes.indexOf(document.documentElement.dataset.theme)+1)%themes.length])); applyTheme(localStorage.getItem('importarr-theme')||'auto'); const themes=['auto','light','dark']; function applyTheme(theme){ document.documentElement.dataset.theme=theme; localStorage.setItem('importarr-theme',theme); document.getElementById('theme-toggle').textContent=`Theme: ${theme}`; } document.getElementById('theme-toggle').addEventListener('click',()=>applyTheme(themes[(themes.indexOf(document.documentElement.dataset.theme)+1)%themes.length])); applyTheme(localStorage.getItem('importarr-theme')||'auto');
document.getElementById('force-run').addEventListener('click', async()=>{ await postJson('/api/import/run-now',{force:true}); await refresh(); }); document.getElementById('force-run').addEventListener('click', async()=>{ await postJson('/api/import/run-now',{force:true}); await refresh(); });
refresh(); setInterval(refresh, 2000); document.getElementById('update-now').addEventListener('click', async()=>{ if(!confirm('Update Importarr now?')) return; const response=await postJson('/api/control/update'); if(response.ok){ const data=await response.json(); renderUpdateCheck(data); alert(data.command_result?.stdout||data.status||'Update command started.'); } });
refresh(); checkForUpdates(); setInterval(refresh, 2000); setInterval(checkForUpdates, 3600000);
</script> </script>
</body> </body>
</html> </html>
+22 -16
View File
@@ -69,6 +69,10 @@ def test_index_packs_secondary_controls_into_menu(tmp_path, monkeypatch):
assert "e.target===e.currentTarget" in response.text assert "e.target===e.currentTarget" in response.text
assert "function readiness" in response.text assert "function readiness" in response.text
assert 'title="${esc(j.reason||j.state)}"' in response.text assert 'title="${esc(j.reason||j.state)}"' in response.text
assert "update-banner" in response.text
assert "checkForUpdates()" in response.text
assert "/api/control/update-check" in response.text
assert "update-now" in response.text
def test_stylesheet_includes_theme_and_progress_rules(): def test_stylesheet_includes_theme_and_progress_rules():
@@ -79,7 +83,9 @@ def test_stylesheet_includes_theme_and_progress_rules():
assert 'html[data-theme="light"]' in css assert 'html[data-theme="light"]' in css
assert 'html[data-theme="dark"]' in css assert 'html[data-theme="dark"]' in css
assert ".current-panel progress" in css assert ".current-panel progress" in css
assert ".top-controls>button,.menu summary" in css assert ".top-controls>button" in css
assert ".update-banner" in css
assert "#ff9f0a" in css
def test_index_renders_settings_dialog(tmp_path, monkeypatch): def test_index_renders_settings_dialog(tmp_path, monkeypatch):
@@ -122,23 +128,23 @@ def test_stylesheet_includes_mobile_responsive_rules():
assert "@media (max-width:640px)" in css assert "@media (max-width:640px)" in css
assert ".table-scroll" in css assert ".table-scroll" in css
assert "overflow-x:auto" in css assert "overflow: hidden" in css
assert "flex-direction:column" in css assert "flex-direction: column" in css
assert ".jobs-table{display:none}" in css assert ".jobs-table { display: none" in css
assert ".job-cards{display:block}" in css assert ".job-cards { display: block" in css
assert "word-break:break-word" in css assert "word-break: break-word" in css
assert "max-width:1280px" in css assert "max-width: 1280px" in css
assert ".jobs-table table{table-layout:fixed;min-width:0}" in css assert "table-layout: fixed" in css
assert ".jobs-table th:nth-child(1){width:66%}" in css assert ".jobs-table th:nth-child(1) { width: 66%" in css
assert ".row-actions button{width:1.85rem" in css assert ".row-actions button { width: 1.85rem" in css
assert ".summary-strip" in css assert ".summary-strip" in css
assert ".menu-panel" in css assert ".menu-panel" in css
assert "@media (prefers-color-scheme:dark)" in css assert "@media (prefers-color-scheme: dark)" in css
assert "--primary:#4b42b8" in css assert "--primary: #4b42b8" in css
assert "--cyan:#50dce5" in css assert "--cyan: #50dce5" in css
assert "--bg:#fff" in css assert "--bg: #fff" in css
assert "--bg:#2f3d55" in css assert "--bg: #2f3d55" in css
assert "--danger:#a56af0" in css assert "--danger: #a56af0" in css
def test_settings_endpoint_persists_arr_connection_values(tmp_path, monkeypatch): def test_settings_endpoint_persists_arr_connection_values(tmp_path, monkeypatch):