Files
felhom-controller/controller/internal/web/templates/app_data.html
T
admin 7f3e58ff0a
gates / gates (push) Failing after 49s
The app page as one column — the approved mockup
v0.315.0 worked and looked, in the operator's words, messy: boxes of different widths stacked down
the page, with the same card shape around things you act on and things you only read. The mockup
approved on 2026-10-15 replaces it, and each of its three rules has a reason:

  ONE COLUMN OF ONE WIDTH (820px). Differing widths read as a page assembled by accident.
  SECTIONS ARE HAIRLINES, NOT CARDS. A card says "this is a thing you act on"; most of an app page
    is a thing you READ, so a card there is a promise the content cannot keep.
  A BOX MEANS SOMETHING. Exactly two remain: blue when the app is waiting for the household,
    amber when something is risky. The exception-colour principle applied to shape as well.

What moved:
  - the hero loses its box: a 64px logo tile, the name at 26px, the tagline, then hairline chips
    (state, lifecycle, update) and meta text (the address, the last automatic update);
  - the tabs are links with a 2px blue underline under the open one — no pills, no fills;
  - sections are `padding: 28px 0` with a bottom hairline; rows are a 200px label column so every
    value on the page starts at the same x, and stack under 720px;
  - the after-start callout is now the FIRST thing on Beallitasok. A household opening a Plex that
    wants its claim code should meet the code box, not a Restart button;
  - "2. mentes beallitasa — <app>" and "<app> — Exportalas" are gone: they were the headings of
    pages that no longer exist, on a tab that already says which app this is;
  - the settings tab gains "Cim es kulcsok" with one address row, and the disabled subdomain input
    that said the same thing is skipped for an installed app (the install wizard still asks);
  - the "this app is already installed, the settings are read-only" BANNER becomes the hint under
    the fields it is about;
  - the install wizard gets the same back link, hero and column, so pressing Telepites does not
    land the household on a page that looks like a different product.

THE AUTO-UPDATE SUCCESS BANNER IS GONE. It was a full-width blue box above the tabs, level with
"the update failed" and "your files are missing". A success that asked nothing of the household is
a date, and it is now meta text in the hero. Every banner that DOES need a decision — a failed
update, a hold, lost files, a missing drive, an interrupted install — is untouched, above the tabs,
exactly where R-571 puts it.

Tests: TestAppPage_OneColumnLayout (one column, one aria-current, no card-shaped section, no
success banner), TestAppPage_AutoUpdateSuccessIsMetaNotBanner, TestAppPage_CalloutsOnlyForAskAndWarn
(one blue callout on a waiting app and it comes first; one amber on a same-disk backup; none on the
good case). Four red-proofs recorded in REPORT.

app_row_dedup_gate convicted `.app-row`, correctly: that class already means "a row in the app
list". The app page's label/value row is `.app-field-row`. Restoring the list's own rule after an
over-broad rename was part of the fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
2026-10-15 20:29:56 +02:00

504 lines
24 KiB
HTML

{{define "app_data"}}
{{template "layout_start" .}}
<div class="app-page">
{{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-section" 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-section" 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}}
{{/* v0.316.0: a SECTION, not a page title over a card. „2. mentés beállítása — <app>" was the
heading of a page that no longer exists; the tab already says which app this is. */}}
<div class="app-section">
<h3>{{T "tier2_config.2_mentes_beallitasa"}}</h3>
{{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="app-callout" style="margin-top:1rem" data-system-backup="protected">
{{T "tier2_config.rendszerlemez.vedett"}}
</div>
{{else if eq $.SystemBackup "same_disk"}}
<div class="app-callout app-callout-warn" style="margin-top:1rem" data-system-backup="same_disk">
{{T "tier2_config.rendszerlemez.ugyanaz"}}
</div>
{{else if eq $.SystemBackup "absent"}}
<div class="app-callout app-callout-warn" style="margin-top:1rem" data-system-backup="absent">
{{T "tier2_config.rendszerlemez.hianyzik"}}
</div>
{{else}}
<div class="app-callout app-callout-warn" 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="app-callout app-callout-warn" 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}}
<div class="app-section">
<h3>{{T "app_export.exportalas"}}</h3>
<h4 style="font-size:14px;font-weight:600;margin:12px 0 6px">{{T "app_export.mentes_helye"}}</h4>
<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" .}}
</div>{{/* .app-page */}}
{{template "layout_end" .}}
{{end}}