v0.315.0: one app page with four tabs; the guide the catalog can carry; no Raspberry Pi badge
gates / gates (push) Failing after 47s

An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.

  settings  Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
            household + the install-time and optional settings. The default for an installed app,
            RENDERED at the bare URL and never redirected to.
  data      the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
  guide     tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
            link — and the WHOLE page for an app that is not installed
  logs      the log, and the only tab whose poller runs

The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.

The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.

A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.

New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.

The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).

template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
This commit is contained in:
2026-10-15 18:37:50 +02:00
parent 85f65c70d8
commit e66d4669d6
120 changed files with 16239 additions and 9866 deletions
@@ -0,0 +1,499 @@
{{define "app_data"}}
{{template "layout_start" .}}
{{template "app_shell_start" .}}
{{/* „ADATOK ES MENTES" — everything about this app's FILES, in the order the household asks it:
where are they -> „Hova tegyem a fajlokat?" and the move-to-another-drive offer
are they safe -> the nightly copy, and the 2nd-copy setting that used to be reachable only
from the backup page's „Beallitas" button
how do I get them out -> the export, which was a page of its own
Both forms keep the URLs they always POSTed to (`/stacks/{name}/backup` and the export API), so
nothing about saving changed — only where the household finds the controls. */}}
<div class="app-data-tab">
{{if .Flash}}<div class="monitoring-banner monitoring-banner-green">{{.Flash}}</div>{{end}}
{{if .FlashError}}<div class="monitoring-banner monitoring-banner-red">{{.FlashError}}</div>{{end}}
{{if .DataPathCards}}
<div class="app-info-card" style="margin-top:1rem">
<h3>{{T "app_info.hova_tegyem_a_fajlokat"}}</h3>
<p class="form-hint">{{T "app_info.ezeket_a_mappakat_a_fajlkezeloben"}}</p>
<div class="datapath-list">
{{range .DataPathCards}}
<div class="datapath-row">
<div class="datapath-head">
<a href="{{.Link}}" target="_blank" rel="noopener" class="datapath-link">{{.Label}} ↗</a>
{{if .FreeSpace}}<span class="datapath-space">{{.FreeSpace}}</span>{{end}}
</div>
{{if .Consequence}}<p class="datapath-note">{{.Consequence}}</p>{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
{{if and .Stack.Deployed .MigrateTargets}}
<div class="app-info-card" style="margin-top:1rem">
<h3>{{T "app_info.athelyezes_masik_tarhelyre"}}</h3>
<p class="form-hint">{{T "app_info.ennek_az_alkalmazasnak_az_adatait"}}</p>
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-top:.5rem">
<select id="app-migrate-target" class="btn btn-sm btn-outline">
<option value="">{{T "app_info.valassz_celtarhelyet"}}</option>
{{range .MigrateTargets}}<option value="{{.Path}}">{{.Label}} ({{.Path}})</option>{{end}}
</select>
<button class="btn btn-sm btn-outline" onclick="appMigrate(this,'{{.Stack.Name}}','{{.Meta.DisplayName}}')">{{T "app_info.athelyezes"}}</button>
</div>
<div id="app-migrate-progress" style="display:none;margin-top:.75rem;padding:.75rem;border:1px solid var(--blue);border-radius:6px;background:rgba(0,136,204,0.06)"></div>
</div>
<script>
function appMigFmtGB(b){ return fhNum(Number(b||0)/1e9, 1)+' GB'; }
function appMigRender(job){
var names={stop:'{{T "app_info.leallitas"}}',copy:'{{T "app_info.adatok_masolasa"}}',verify:'{{T "app_info.ellenorzes"}}',flip:'{{T "app_info.ujratelepites"}}',redeploy:'{{T "app_info.ujratelepites"}}',cleanup:'{{T "app_info.regi_adatok_torlese"}}'};
var s=names[job.phase]||job.phase;
if(job.phase==='copy'&&job.bytes_total>0){ s+=' ('+Math.floor(100*job.bytes_done/job.bytes_total)+'% — '+appMigFmtGB(job.bytes_done)+'/'+appMigFmtGB(job.bytes_total)+')'; }
return s+'…';
}
function appMigWatch(){
var panel=document.getElementById('app-migrate-progress');
if(panel) panel.style.display='block';
function tick(){
fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){
var job=d.data&&d.data.job;
if(!job){ if(panel) panel.textContent='{{T "app_info.nincs_folyamatban_athelyezes"}}'; return; }
if(panel) panel.innerHTML=appMigRender(job);
if(job.phase==='done'){ if(panel) panel.innerHTML+='<br><strong>{{T "app_info.kesz"}}</strong>'; setTimeout(function(){location.reload();},1500); return; }
if(job.phase==='aborted'){ if(panel) panel.innerHTML+='<br><strong style="color:var(--danger,#c0392b)">{{T "app_info.megszakadt"}} '+(job.error||'')+'</strong><br>{{T "app_info.a_regi_adatok_erintetlenek"}}'; return; }
setTimeout(tick,1500);
}).catch(function(){ setTimeout(tick,2000); });
}
tick();
}
function appMigrate(btn,app,label){
var sel=document.getElementById('app-migrate-target');
var target=sel?sel.value:'';
if(!target){ showAlert('{{T "app_info.valassz_celtarhelyet_2"}}'); return; }
felhomConfirm(btn,'{{T "app_info.athelyezed_a_z"}} '+label+' {{T "app_info.adatait_ide"}} '+target+'{{T "app_info.az_alkalmazas_rovid_idore_leall"}}',function(){
fetch('/api/storage/migrate-app',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({app:app,target:target})})
.then(function(r){return r.json();}).then(function(d){ if(d.ok){ appMigWatch(); } else { showAlert('{{T "common.hiba_kettospont"}} '+(d.error||'{{T "app_info.ismeretlen"}}')); } })
.catch(function(e){ showAlert('{{T "common.hiba_kettospont"}} '+e); });
});
}
(function(){ fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){ if(d.data&&d.data.job){ appMigWatch(); } }).catch(function(){}); })();
</script>
{{end}}
{{/* The nightly copy, stated as a fact. It was a card on the settings page; the files it is about
live here. */}}
<div class="deploy-cross-drive">
<h4>{{T "deploy.biztonsagi_mentes"}}</h4>
<div class="cross-drive-nightly">
<span class="form-hint" style="display:block;margin-top:.25rem">
{{T "deploy.az_alkalmazas_adatbazisa_es_docker"}}
</span>
</div>
</div>
{{if .Tier2}}
<h3 style="margin-top:1.5rem">{{T "tier2_config.2_mentes_beallitasa"}}</h3>
<div class="monitor-card">
{{with .Tier2}}
<p style="color:var(--text-3);font-size:.9rem;margin-top:0">
{{T "tier2_config.a_2_mentes_egy_masik"}}
</p>
{{if not .IsHDDApp}}
{{if eq $.SystemBackup "protected"}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="protected">
{{T "tier2_config.rendszerlemez.vedett"}}
</div>
{{else if eq $.SystemBackup "same_disk"}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="same_disk">
{{T "tier2_config.rendszerlemez.ugyanaz"}}
</div>
{{else if eq $.SystemBackup "absent"}}
<div class="monitoring-banner monitoring-banner-red" style="margin-top:1rem" data-system-backup="absent">
{{T "tier2_config.rendszerlemez.hianyzik"}}
</div>
{{else}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="unknown">
{{T "tier2_config.rendszerlemez.ismeretlen"}}
</div>
{{end}}
{{else}}
<h3 style="margin-top:1.5rem">{{T "tier2_config.jelenlegi_allapot"}}</h3>
<div class="sysinfo-grid">
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.2_mentes"}}</span>
<span class="sysinfo-value">{{if .Disabled}}{{T "tier2_config.kikapcsolva"}}{{else}}{{T "tier2_config.bekapcsolva"}}{{end}}</span>
</div>
{{if .NoTarget}}
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.cel"}}</span>
<span class="sysinfo-value text-error">{{T "tier2_config.nincs_elerheto_off_drive_cel"}}</span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.megjegyzes"}}</span>
<span class="sysinfo-value" style="color:var(--text-3)">{{.NoTargetReason}}</span>
</div>
{{else}}
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.cel_meghajto"}}</span>
<span class="sysinfo-value">{{.EffectiveLabel}}{{if .EffectiveIsSSD}} {{T "tier2_config.csak_db_konfiguracio"}}{{end}}</span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.kivalasztas_modja"}}</span>
<span class="sysinfo-value" style="color:var(--text-3)">{{if .Preferred}}{{T "tier2_config.kezi_valasztas"}}{{else}}{{T "tier2_config.automatikus_kisbetu"}}{{end}} — {{.EffectiveDesc}}</span>
</div>
{{end}}
</div>
{{if .EffectiveIsSSD}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem">
{{T "tier2_config.jelenleg_csak_a_belso_ssd"}}
</div>
{{end}}
<form method="POST" action="/stacks/{{$.StackName}}/backup" style="margin-top:1.5rem">
{{$.CSRFField}}
<div class="form-group">
<label style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" name="enabled" value="on" {{if not .Disabled}}checked{{end}}>
{{T "tier2_config.2_mentes_bekapcsolva"}}
</label>
</div>
<div class="form-group">
<label for="target">{{T "tier2_config.cel_meghajto"}}</label>
<select id="target" name="target" class="form-control">
<option value="">{{T "tier2_config.automatikus"}}{{if not .NoTarget}} {{T "tier2_config.jelenleg"}}{{end}}</option>
{{range .Alternatives}}
<option value="{{.Path}}" {{if eq .Path $.Tier2.Preferred}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
{{if not .Alternatives}}
<div style="font-size:.8rem;color:var(--text-3);margin-top:.4rem">
{{T "tier2_config.nincs_masik_adatmeghajto_automatikus_cel"}}
</div>
{{end}}
</div>
<button type="submit" class="btn btn-primary">{{T "tier2_config.mentes"}}</button>
</form>
{{end}}
{{end}}
</div>
{{end}}
{{if .ExportOffered}}
<h3 style="margin-top:2rem">{{T "app_export.exportalas"}}</h3>
<div class="card" style="max-width:700px">
<h3>{{T "app_export.mentes_helye"}}</h3>
<select id="destDrive" onchange="loadEstimate()" style="width:100%;padding:.5rem;margin-bottom:1rem">
<option value="">{{T "app_export.valassz_tarolot"}}</option>
{{range .Drives}}
<option value="{{.Path}}">{{if .Label}}{{.Label}} ({{.Path}}){{else}}{{.Path}}{{end}}</option>
{{end}}
</select>
<div id="estimateBox" style="display:none;background:var(--bg-2);border-radius:8px;padding:1rem;margin-bottom:1.5rem">
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
<span>{{T "app_export.konfiguracio"}}</span>
<span id="estConfig">-</span>
</div>
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
<span>{{T "app_export.felhasznaloi_adatok"}}</span>
<span id="estData">-</span>
</div>
<div style="display:flex;justify-content:space-between;font-weight:600;margin-bottom:.25rem;border-top:1px solid var(--line);padding-top:.5rem">
<span>{{T "app_export.osszesen"}}</span>
<span><span id="estTotal">-</span> <span id="estFree" style="color:var(--text-3)">{{T "app_export.szabad"}}</span></span>
</div>
<div style="display:flex;justify-content:space-between">
<span>{{T "app_export.becsult_ido"}}</span>
<span id="estTime">-</span>
</div>
<div id="estWarning" style="display:none;color:var(--crit);margin-top:.5rem;font-weight:600"></div>
</div>
<div id="classSelect" style="display:none;margin-bottom:1.5rem">
<div id="mandBox" style="display:none;margin-bottom:1rem">
<div style="font-weight:600;margin-bottom:.25rem">{{T "app_export.mindig_resze_a_mentesnek"}}</div>
<div id="mandList" style="color:var(--text-3);font-size:.9rem"></div>
</div>
<div id="optBox" style="display:none;margin-bottom:1rem">
<div style="font-weight:600;margin-bottom:.25rem">{{T "app_export.valaszthato_tartalom"}}</div>
<div id="optList"></div>
</div>
<div id="exclBox" style="display:none">
<div id="exclSummary" style="margin-bottom:.25rem"></div>
<div style="color:var(--text-3);font-size:.85rem;margin-bottom:.5rem">{{T "app_export.a_kihagyott_mappak_tartalma_a"}}</div>
<button type="button" class="btn btn-xs btn-outline" onclick="toggleExcl()" id="exclToggle">{{T "app_export.kihagyott_tartalom_megjelenitese"}}</button>
<div id="exclList" style="display:none;margin-top:.5rem"></div>
</div>
</div>
<h3>{{T "app_export.jelszo_opcionalis"}}</h3>
<div style="display:flex;gap:.5rem;margin-bottom:1rem">
<input type="password" id="exportPassword" placeholder="{{T "app_export.titkositasi_jelszo"}}" style="flex:1;padding:.5rem">
<button class="btn btn-sm btn-outline" onclick="togglePw()" type="button" title="{{T "app_export.jelszo_mutatasa"}}">&#128065;</button>
</div>
<label style="display:flex;align-items:flex-start;gap:.5rem;margin-bottom:.5rem;cursor:pointer">
<input type="checkbox" id="stopApp" checked style="margin-top:3px">
<div>
<strong>{{T "app_export.alkalmazas_leallitasa_mentes_elott_ajanl"}}</strong>
<div style="color:var(--text-3);font-size:.85rem">{{T "app_export.az_adatok_konzisztenciaja_erdekeben_java"}}</div>
</div>
</label>
<button id="startBtn" class="btn btn-primary" onclick="startExport()" style="margin-top:1rem;width:100%" disabled>
{{T "app_export.exportalas_inditasa"}}
</button>
</div>
<div id="progressCard" class="card" style="max-width:700px;display:none">
<h3>{{T "app_export.folyamat"}}</h3>
<div id="progressSteps"></div>
<div id="progressError" style="display:none;color:var(--crit);margin-top:1rem;font-weight:600"></div>
</div>
<div id="doneCard" class="card" style="max-width:700px;display:none">
<h3 style="color:var(--blue)">{{T "app_export.kesz"}}</h3>
<div style="margin-bottom:1rem">
<span id="doneFile" style="font-weight:600"></span>
<span id="doneSize" style="color:var(--text-3)"></span>
</div>
<a id="doneFBLink" href="#" target="_blank" class="btn btn-outline">{{T "app_export.megnyitas_filebrowser_ben"}}</a>
</div>
<script>
var stackName = '{{.Stack.Name}}';
var domain = '{{.Stack.Meta.Subdomain}}' ? '{{.Stack.Meta.Subdomain}}.{{$.Domain}}' : '';
var pollTimer = null;
function csrfH() {
var el = document.querySelector('meta[name="csrf-token"]');
return el ? {'X-CSRF-Token': el.content, 'Content-Type': 'application/json'} : {'Content-Type': 'application/json'};
}
function togglePw() {
var inp = document.getElementById('exportPassword');
inp.type = inp.type === 'password' ? 'text' : 'password';
}
async function loadEstimate() {
var drive = document.getElementById('destDrive').value;
var box = document.getElementById('estimateBox');
var btn = document.getElementById('startBtn');
if (!drive) {
box.style.display = 'none';
btn.disabled = true;
return;
}
try {
var resp = await fetch('/api/export/estimate?stack=' + encodeURIComponent(stackName) + '&drive=' + encodeURIComponent(drive));
var data = await resp.json();
if (!data.ok) {
box.style.display = 'none';
btn.disabled = true;
return;
}
var est = data.data;
document.getElementById('estConfig').textContent = est.config_size_human;
document.getElementById('estData').textContent = est.data_size_human;
document.getElementById('estTotal').textContent = '~' + est.total_size_human;
document.getElementById('estFree').textContent = '{{T "app_export.szabad_2"}} ' + est.dest_free_human + ')';
document.getElementById('estTime').textContent = '~' + est.estimated_minutes + ' {{T "app_export.perc"}}';
var warn = document.getElementById('estWarning');
if (!est.fits_on_dest) {
warn.textContent = '{{T "app_export.nincs_eleg_szabad_hely_a"}}';
warn.style.display = 'block';
btn.disabled = true;
} else {
warn.style.display = 'none';
btn.disabled = false;
}
box.style.display = 'block';
renderClassSelect(est);
} catch(e) {
console.error('Estimate error:', e);
}
}
// Task 4: the class-scoped selection UI (classified apps only; legacy apps see the plain estimate).
var lastEst = null;
function humanBytes(b) {
if (b >= 1073741824) return fhNum(b/1073741824, 1) + ' GB';
if (b >= 1048576) return fhNum(b/1048576, 1) + ' MB';
if (b >= 1024) return fhNum(b/1024, 1) + ' KB';
return b + ' B';
}
function renderClassSelect(est) {
lastEst = est;
var wrap = document.getElementById('classSelect');
if (!est.has_classification) { wrap.style.display = 'none'; return; }
wrap.style.display = 'block';
var mand = est.mandatory_items || [], opt = est.optional_items || [], excl = est.excluded_items || [];
// Mandatory (locked).
var mb = document.getElementById('mandBox');
if (mand.length) {
mb.style.display = 'block';
document.getElementById('mandList').innerHTML = mand.map(function(i){
return '<div>' + esc(i.rel_path) + ' <span style="opacity:.7">(' + i.human + ')</span></div>';
}).join('');
} else { mb.style.display = 'none'; }
// Optional (pre-selected checkboxes).
var ob = document.getElementById('optBox');
if (opt.length) {
ob.style.display = 'block';
document.getElementById('optList').innerHTML = opt.map(function(i){
return '<label style="display:flex;gap:.5rem;align-items:center;cursor:pointer;padding:.15rem 0">' +
'<input type="checkbox" class="fab-opt" checked data-key="' + esc(i.key) + '" data-bytes="' + i.bytes + '" onchange="recomputeTotal()">' +
'<span>' + esc(i.rel_path) + ' <span style="opacity:.7">(' + i.human + ')</span></span></label>';
}).join('');
} else { ob.style.display = 'none'; }
// Excluded (opt-in, collapsed, behind the two-number warning).
var eb = document.getElementById('exclBox');
if (excl.length) {
eb.style.display = 'block';
document.getElementById('exclList').innerHTML = excl.map(function(i){
return '<label style="display:flex;gap:.5rem;align-items:center;cursor:pointer;padding:.15rem 0">' +
'<input type="checkbox" class="fab-excl" data-key="' + esc(i.key) + '" data-bytes="' + i.bytes + '" onchange="recomputeTotal()">' +
'<span>' + esc(i.rel_path) + ' <span style="opacity:.7">(' + i.human + ')</span></span></label>';
}).join('');
} else { eb.style.display = 'none'; }
recomputeTotal();
}
function recomputeTotal() {
if (!lastEst) return;
var base = lastEst.base_bytes || 0;
var optSum = 0, exclSum = 0, exclAll = 0;
document.querySelectorAll('.fab-opt').forEach(function(cb){ if (cb.checked) optSum += parseInt(cb.dataset.bytes,10)||0; });
document.querySelectorAll('.fab-excl').forEach(function(cb){ var b = parseInt(cb.dataset.bytes,10)||0; exclAll += b; if (cb.checked) exclSum += b; });
var alap = base + optSum;
var full = alap + exclAll;
var s = document.getElementById('exclSummary');
if ((lastEst.excluded_items||[]).length) {
s.innerHTML = '{{T "app_export.alap_mentes"}}' + humanBytes(alap) + '{{T "app_export.a_kihagyott_nagy_meretu_tartalommal"}}' + humanBytes(full) + '.';
}
}
function toggleExcl() {
var l = document.getElementById('exclList');
var shown = l.style.display !== 'none';
l.style.display = shown ? 'none' : 'block';
document.getElementById('exclToggle').textContent = shown ? '{{T "app_export.kihagyott_tartalom_megjelenitese"}}' : '{{T "app_export.kihagyott_tartalom_elrejtese"}}';
}
function gatherSelections() {
var deselect = [], optIn = [];
document.querySelectorAll('.fab-opt').forEach(function(cb){ if (!cb.checked) deselect.push(cb.dataset.key); });
document.querySelectorAll('.fab-excl').forEach(function(cb){ if (cb.checked) optIn.push(cb.dataset.key); });
return { deselect_optional: deselect, opt_in_excluded: optIn };
}
function esc(s) { return String(s).replace(/[&<>"]/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]; }); }
async function startExport() {
var drive = document.getElementById('destDrive').value;
var password = document.getElementById('exportPassword').value;
var stopApp = document.getElementById('stopApp').checked;
document.getElementById('startBtn').disabled = true;
document.getElementById('progressCard').style.display = 'block';
document.getElementById('doneCard').style.display = 'none';
try {
var sel = gatherSelections();
var resp = await fetch('/api/export/start', {
method: 'POST',
headers: csrfH(),
body: JSON.stringify({
stack_name: stackName,
dest_drive: drive,
password: password,
stop_app: stopApp,
deselect_optional: sel.deselect_optional,
opt_in_excluded: sel.opt_in_excluded
})
});
var data = await resp.json();
if (!data.ok) {
showError(data.error || '{{T "common.hiba_tortent"}}');
return;
}
pollStatus();
} catch(e) {
showError(e.message);
}
}
function pollStatus() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(async function() {
try {
var resp = await fetch('/api/export/status');
var data = await resp.json();
renderSteps(data.steps || []);
if (data.error) {
showError(data.error);
clearInterval(pollTimer);
return;
}
if (data.done && !data.error) {
clearInterval(pollTimer);
showDone(data);
}
} catch(e) {
console.error('Poll error:', e);
}
}, 1000);
}
function renderSteps(steps) {
var html = '';
for (var i = 0; i < steps.length; i++) {
var s = steps[i];
var icon = '&#9675;'; // pending
if (s.status === 'running') icon = '&#10227;';
if (s.status === 'done') icon = '&#10003;';
if (s.status === 'failed') icon = '&#10007;';
var cls = s.status === 'failed' ? 'color:var(--crit)' : s.status === 'done' ? 'color:var(--blue)' : s.status === 'running' ? 'color:var(--blue)' : '';
html += '<div style="padding:.25rem 0;' + cls + '">' + icon + ' ' + s.label;
if (s.error) html += ' <span style="font-size:.85rem">(' + s.error + ')</span>';
html += '</div>';
}
document.getElementById('progressSteps').innerHTML = html;
}
function showError(msg) {
var el = document.getElementById('progressError');
el.textContent = msg;
el.style.display = 'block';
document.getElementById('startBtn').disabled = false;
}
function showDone(data) {
document.getElementById('progressCard').style.display = 'none';
document.getElementById('doneCard').style.display = 'block';
var fileName = (data.output_path || '').split('/').pop();
document.getElementById('doneFile').textContent = fileName;
document.getElementById('doneSize').textContent = data.output_size ? '(' + data.output_size + ')' : '';
// Build FileBrowser link to the exports directory
var drive = document.getElementById('destDrive').value;
var fbPath = drive + '/felhom-data/exports/';
document.getElementById('doneFBLink').href = 'https://files.' + location.hostname.split('.').slice(-2).join('.') + '/files' + fbPath;
}
</script>
{{end}}
</div>
{{template "app_press_script" .}}
{{template "layout_end" .}}
{{end}}