e66d4669d6
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
447 lines
19 KiB
HTML
447 lines
19 KiB
HTML
{{define "app_import"}}
|
|
{{template "layout_start" .}}
|
|
|
|
<div class="page-header">
|
|
<div style="display:flex;align-items:center;gap:1rem">
|
|
<a href="/stacks" class="btn btn-sm btn-outline">{{T "app_import.alkalmazasok"}}</a>
|
|
<h2>{{T "app_import.alkalmazas_importalas"}}</h2>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Browser .fab upload (v0.128.0) — chunked, because the Cloudflare tunnel caps request bodies (~100 MB) -->
|
|
<div class="card" style="max-width:900px;margin-bottom:1rem">
|
|
<div id="fabUploadZone" style="border:1px dashed var(--line);border-radius:var(--radius);padding:1.25rem;text-align:center;cursor:pointer" onclick="fabPick()">
|
|
<p style="color:var(--text-2);margin:0 0 .75rem">{{T "app_import.fab_csomag_feltoltese_huzza_ide"}}</p>
|
|
<button type="button" class="btn btn-sm btn-outline">{{T "app_import.fajl_kivalasztasa"}}</button>
|
|
<input type="file" id="fabFileInput" accept=".fab" style="display:none">
|
|
</div>
|
|
<div id="fabUpProgress" style="display:none;margin-top:1rem">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;gap:1rem">
|
|
<span id="fabUpText" style="color:var(--text-2)">{{T "app_import.feltoltes_0"}}</span>
|
|
<button type="button" class="btn btn-sm btn-outline" onclick="fabCancel()">{{T "app_import.megszakitas"}}</button>
|
|
</div>
|
|
<div style="background:var(--bg-2);border-radius:var(--radius);height:6px;margin-top:.5rem;overflow:hidden">
|
|
<div id="fabUpBar" style="background:var(--blue);height:100%;width:0%"></div>
|
|
</div>
|
|
</div>
|
|
<div id="fabUpError" style="display:none;color:var(--crit);margin-top:.75rem"></div>
|
|
</div>
|
|
|
|
{{if not .Bundles}}
|
|
<div class="card" style="max-width:700px">
|
|
<p style="color:var(--text-3)">{{T "app_import.nem_talalhato_fab_csomag_a"}}</p>
|
|
<p style="color:var(--text-3);font-size:.85rem">{{T "app_import.exportalj_egy_alkalmazast_az_alkalmazas"}}</p>
|
|
</div>
|
|
{{else}}
|
|
<div class="card" style="max-width:900px">
|
|
<table class="table" style="width:100%">
|
|
<thead>
|
|
<tr>
|
|
<th>{{T "app_import.alkalmazas"}}</th>
|
|
<th>{{T "app_import.datum"}}</th>
|
|
<th>{{T "app_import.meret"}}</th>
|
|
<th>{{T "app_import.tarolo"}}</th>
|
|
<th>{{T "app_import.titkos"}}</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{{range .Bundles}}
|
|
<tr>
|
|
<td><strong>{{if .DisplayName}}{{.DisplayName}}{{else}}{{.AppName}}{{end}}</strong></td>
|
|
<td>{{fmtTimeStr .ExportedAt}}</td>
|
|
<td>{{numText .SizeHuman}}</td>
|
|
<td>{{if .DriveLabel}}{{.DriveLabel}}{{else}}{{.DrivePath}}{{end}}</td>
|
|
<td>{{if .Encrypted}}🔒{{end}}</td>
|
|
<td><button class="btn btn-sm btn-outline" onclick="showPreview('{{.Path}}', {{.Encrypted}})">{{T "app_import.reszletek"}}</button></td>
|
|
</tr>
|
|
{{end}}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{{end}}
|
|
|
|
<!-- Preview / password modal -->
|
|
<div id="previewCard" class="card" style="max-width:700px;display:none">
|
|
<h3 id="previewTitle">{{T "app_import.csomag_reszletei"}}</h3>
|
|
|
|
<div id="passwordPrompt" style="display:none;margin-bottom:1rem">
|
|
<p>{{T "app_import.ez_a_csomag_jelszoval_vedett"}}</p>
|
|
<div style="display:flex;gap:.5rem">
|
|
<input type="password" id="importPassword" class="form-input" autocomplete="off" placeholder="{{T "common.jelszo"}}" style="flex:1">
|
|
<button class="btn btn-primary" onclick="loadManifest()">{{T "app_import.megnyitas"}}</button>
|
|
</div>
|
|
<div id="passwordError" style="display:none;color:var(--crit);margin-top:.5rem"></div>
|
|
</div>
|
|
|
|
<div id="manifestInfo" style="display:none">
|
|
<div style="background:var(--bg-2);border-radius:8px;padding:1rem;margin-bottom:1rem">
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
|
<span>{{T "app_import.alkalmazas_2"}}</span>
|
|
<strong id="mfName">-</strong>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
|
<span>{{T "app_import.exportalva"}}</span>
|
|
<span id="mfDate">-</span>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
|
<span>{{T "app_import.controller_verzio"}}</span>
|
|
<span id="mfVersion">-</span>
|
|
</div>
|
|
<div id="mfDBRow" style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
|
<span>{{T "app_import.adatbazis"}}</span>
|
|
<span id="mfDB">-</span>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between;margin-bottom:.25rem">
|
|
<span>{{T "app_import.adatok"}}</span>
|
|
<span id="mfDataType">-</span>
|
|
</div>
|
|
<div style="display:flex;justify-content:space-between">
|
|
<span>{{T "app_import.meret_2"}}</span>
|
|
<span id="mfSize">-</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="overwriteWarning" style="display:none;background:var(--warning-bg, #fff3cd);border:1px solid var(--warning-border, #ffc107);border-radius:8px;padding:1rem;margin-bottom:1rem">
|
|
{{T "app_import.figyelem_a_meglevo"}} <span id="overwriteAppName"></span> {{T "app_import.alkalmazas_konfiguracioja_es_osszes_adat"}}
|
|
</div>
|
|
|
|
<button id="importBtn" class="btn btn-primary" onclick="startImport()" style="width:100%">
|
|
{{T "app_import.visszaallitas_inditasa"}}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Progress -->
|
|
<div id="progressCard" class="card" style="max-width:700px;display:none">
|
|
<h3>{{T "app_import.importalas_folyamata"}}</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_import.importalas_kesz"}}</h3>
|
|
<p>{{T "app_import.az_alkalmazas_sikeresen_visszaallitva"}}</p>
|
|
<a id="doneLink" href="/stacks" class="btn btn-primary">{{T "app_import.alkalmazasok_megtekintese"}}</a>
|
|
</div>
|
|
|
|
<script>
|
|
var selectedPath = '';
|
|
var selectedEncrypted = false;
|
|
var selectedManifest = null;
|
|
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 showPreview(path, encrypted) {
|
|
selectedPath = path;
|
|
selectedEncrypted = encrypted;
|
|
selectedManifest = null;
|
|
|
|
document.getElementById('previewCard').style.display = 'block';
|
|
document.getElementById('manifestInfo').style.display = 'none';
|
|
document.getElementById('passwordPrompt').style.display = 'none';
|
|
document.getElementById('passwordError').style.display = 'none';
|
|
document.getElementById('progressCard').style.display = 'none';
|
|
document.getElementById('doneCard').style.display = 'none';
|
|
|
|
if (encrypted) {
|
|
document.getElementById('passwordPrompt').style.display = 'block';
|
|
document.getElementById('importPassword').value = '';
|
|
document.getElementById('importPassword').focus();
|
|
} else {
|
|
loadManifest();
|
|
}
|
|
}
|
|
|
|
async function loadManifest() {
|
|
var password = selectedEncrypted ? document.getElementById('importPassword').value : '';
|
|
|
|
try {
|
|
var resp = await fetch('/api/export/manifest', {
|
|
method: 'POST',
|
|
headers: csrfH(),
|
|
body: JSON.stringify({path: selectedPath, password: password})
|
|
});
|
|
var data = await resp.json();
|
|
|
|
if (!data.ok) {
|
|
if (selectedEncrypted) {
|
|
document.getElementById('passwordError').textContent = data.error || '{{T "app_import.hibas_jelszo"}}';
|
|
document.getElementById('passwordError').style.display = 'block';
|
|
}
|
|
return;
|
|
}
|
|
|
|
if (data.needs_password) {
|
|
document.getElementById('passwordPrompt').style.display = 'block';
|
|
document.getElementById('importPassword').focus();
|
|
return;
|
|
}
|
|
|
|
selectedManifest = data.manifest;
|
|
showManifest(data.manifest);
|
|
} catch(e) {
|
|
console.error('Manifest error:', e);
|
|
}
|
|
}
|
|
|
|
function showManifest(m) {
|
|
document.getElementById('passwordPrompt').style.display = 'none';
|
|
document.getElementById('manifestInfo').style.display = 'block';
|
|
|
|
document.getElementById('previewTitle').textContent = (m.display_name || m.app_name) + ' {{T "app_import.csomag_reszletei_2"}}';
|
|
document.getElementById('mfName').textContent = m.display_name || m.app_name;
|
|
document.getElementById('mfDate').textContent = m.exported_at ? fhDateTime(new Date(m.exported_at)) : '-';
|
|
document.getElementById('mfVersion').textContent = m.controller_version || '-';
|
|
|
|
if (m.has_database && m.db_type) {
|
|
document.getElementById('mfDB').textContent = m.db_type;
|
|
document.getElementById('mfDBRow').style.display = 'flex';
|
|
} else {
|
|
document.getElementById('mfDBRow').style.display = 'none';
|
|
}
|
|
|
|
var dataType = [];
|
|
if (m.has_hdd_data) dataType.push('HDD');
|
|
if (m.has_volume_data) dataType.push('Docker volume');
|
|
document.getElementById('mfDataType').textContent = dataType.length ? dataType.join(', ') : '{{T "app_import.nincs"}}';
|
|
|
|
// Format size
|
|
var bytes = m.total_size_bytes || 0;
|
|
var sizeStr = bytes > 1073741824 ? fhNum(bytes / 1073741824, 1) + ' GB' :
|
|
bytes > 1048576 ? fhNum(bytes / 1048576, 1) + ' MB' :
|
|
bytes > 1024 ? fhNum(bytes / 1024, 1) + ' KB' : bytes + ' B';
|
|
document.getElementById('mfSize').textContent = sizeStr;
|
|
|
|
// Check if app already exists — show overwrite warning
|
|
// We detect this by checking if the app link exists in the nav
|
|
var warn = document.getElementById('overwriteWarning');
|
|
// Simple approach: always show warning for existing app names
|
|
document.getElementById('overwriteAppName').textContent = m.display_name || m.app_name;
|
|
warn.style.display = 'block';
|
|
}
|
|
|
|
async function startImport() {
|
|
document.getElementById('importBtn').disabled = true;
|
|
document.getElementById('progressCard').style.display = 'block';
|
|
document.getElementById('doneCard').style.display = 'none';
|
|
|
|
var password = selectedEncrypted ? document.getElementById('importPassword').value : '';
|
|
|
|
try {
|
|
var resp = await fetch('/api/export/import', {
|
|
method: 'POST',
|
|
headers: csrfH(),
|
|
body: JSON.stringify({path: selectedPath, password: password})
|
|
});
|
|
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/import/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);
|
|
document.getElementById('progressCard').style.display = 'none';
|
|
document.getElementById('doneCard').style.display = 'block';
|
|
if (data.stack_name) {
|
|
{{- /* v0.315.0: the browser knows the imported app's stack NAME and not its
|
|
slug, and the name->slug map lives on the server. This is the one link
|
|
that keeps the old URL on purpose: its 302 resolves the slug and lands on
|
|
the settings tab.
|
|
|
|
The leading `{{-` is deliberate: a comment action renders nothing, but the
|
|
indentation on its own line does. Without the trim this page's bytes
|
|
change and its parity fixture — a page this task does not redesign — would
|
|
have to be regenerated for a comment. */}}
|
|
document.getElementById('doneLink').href = '/stacks/' + data.stack_name + '/deploy';
|
|
document.getElementById('doneLink').textContent = '{{T "app_import.alkalmazas_megtekintese"}}';
|
|
}
|
|
}
|
|
} 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 = '○';
|
|
if (s.status === 'running') icon = '⟳';
|
|
if (s.status === 'done') icon = '✓';
|
|
if (s.status === 'failed') icon = '✗';
|
|
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('importBtn').disabled = false;
|
|
}
|
|
|
|
// --- Browser .fab upload (v0.128.0): File.slice sequential chunk loop. One retry per chunk on a
|
|
// network error, re-syncing from the server's 409 received_bytes echo; progress from bytes acked.
|
|
var fabUploadId = null;
|
|
var fabCancelled = false;
|
|
var fabNetErrText = '{{T "app_import.a_feltoltes_megszakadt_ellenorizze_a"}}';
|
|
|
|
function csrfRawH() {
|
|
var el = document.querySelector('meta[name="csrf-token"]');
|
|
return el ? {'X-CSRF-Token': el.content, 'Content-Type': 'application/octet-stream'} : {'Content-Type': 'application/octet-stream'};
|
|
}
|
|
|
|
function fabPick() {
|
|
document.getElementById('fabFileInput').click();
|
|
}
|
|
|
|
function fabGB(b) {
|
|
return fhNum(b / 1073741824, 2);
|
|
}
|
|
|
|
function fabProgress(done, total) {
|
|
var pct = total > 0 ? Math.floor(done * 100 / total) : 0;
|
|
document.getElementById('fabUpText').textContent =
|
|
'{{T "app_import.feltoltes"}} ' + pct + '% (' + fabGB(done) + ' / ' + fabGB(total) + ' GB)';
|
|
document.getElementById('fabUpBar').style.width = pct + '%';
|
|
}
|
|
|
|
function fabFail(msg) {
|
|
document.getElementById('fabUpProgress').style.display = 'none';
|
|
document.getElementById('fabUploadZone').style.display = 'block';
|
|
var el = document.getElementById('fabUpError');
|
|
el.textContent = msg;
|
|
el.style.display = 'block';
|
|
fabUploadId = null;
|
|
}
|
|
|
|
async function fabCancel() {
|
|
fabCancelled = true;
|
|
if (fabUploadId) {
|
|
try {
|
|
await fetch('/api/export/upload/abort', {
|
|
method: 'POST', headers: csrfH(),
|
|
body: JSON.stringify({upload_id: fabUploadId})
|
|
});
|
|
} catch(e) { /* best-effort — the server's idle timeout collects the rest */ }
|
|
}
|
|
fabUploadId = null;
|
|
document.getElementById('fabUpProgress').style.display = 'none';
|
|
document.getElementById('fabUploadZone').style.display = 'block';
|
|
}
|
|
|
|
async function fabStart(file) {
|
|
if (!file) return;
|
|
document.getElementById('fabUpError').style.display = 'none';
|
|
if (!file.name.toLowerCase().endsWith('.fab')) {
|
|
fabFail('{{T "app_import.csak_fab_fajl_toltheto_fel"}}');
|
|
return;
|
|
}
|
|
fabCancelled = false;
|
|
document.getElementById('fabUploadZone').style.display = 'none';
|
|
document.getElementById('fabUpProgress').style.display = 'block';
|
|
fabProgress(0, file.size);
|
|
|
|
try {
|
|
var resp = await fetch('/api/export/upload/init', {
|
|
method: 'POST', headers: csrfH(),
|
|
body: JSON.stringify({filename: file.name, size_bytes: file.size})
|
|
});
|
|
var data = await resp.json();
|
|
if (!data.ok) { fabFail(data.error || fabNetErrText); return; }
|
|
fabUploadId = data.upload_id;
|
|
var chunkBytes = data.chunk_bytes;
|
|
|
|
var offset = 0;
|
|
var retriedThisChunk = false;
|
|
while (offset < file.size) {
|
|
if (fabCancelled) return;
|
|
var r = null;
|
|
try {
|
|
r = await fetch('/api/export/upload/chunk?id=' + fabUploadId + '&offset=' + offset, {
|
|
method: 'POST', headers: csrfRawH(),
|
|
body: file.slice(offset, Math.min(offset + chunkBytes, file.size))
|
|
});
|
|
} catch(e) {
|
|
if (retriedThisChunk) { fabFail(fabNetErrText); return; }
|
|
retriedThisChunk = true;
|
|
continue; // same offset — the server only counts appended bytes
|
|
}
|
|
var d = await r.json().catch(function(){ return {}; });
|
|
if (r.status === 409 && typeof d.received_bytes === 'number') {
|
|
if (retriedThisChunk) { fabFail(d.error || fabNetErrText); return; }
|
|
retriedThisChunk = true;
|
|
offset = d.received_bytes; // re-sync one step from the echo
|
|
fabProgress(offset, file.size);
|
|
continue;
|
|
}
|
|
if (!r.ok || !d.ok) { fabFail(d.error || fabNetErrText); return; }
|
|
offset = d.received_bytes;
|
|
retriedThisChunk = false;
|
|
fabProgress(offset, file.size);
|
|
}
|
|
if (fabCancelled) return;
|
|
|
|
var fin = await fetch('/api/export/upload/finalize', {
|
|
method: 'POST', headers: csrfH(),
|
|
body: JSON.stringify({upload_id: fabUploadId})
|
|
});
|
|
var fd = await fin.json();
|
|
if (!fd.ok) { fabFail(fd.error || fabNetErrText); return; }
|
|
fabUploadId = null;
|
|
document.getElementById('fabUpText').textContent = '{{T "app_import.feltoltes_kesz"}} ' + fd.filename;
|
|
document.getElementById('fabUpBar').style.width = '100%';
|
|
// Refresh the bundle list through the EXISTING scan (the page render runs it).
|
|
setTimeout(function(){ window.location.reload(); }, 800);
|
|
} catch(e) {
|
|
fabFail(fabNetErrText);
|
|
}
|
|
}
|
|
|
|
(function(){
|
|
var input = document.getElementById('fabFileInput');
|
|
input.addEventListener('change', function(){ fabStart(input.files[0]); input.value = ''; });
|
|
var zone = document.getElementById('fabUploadZone');
|
|
zone.addEventListener('dragover', function(e){ e.preventDefault(); zone.style.borderColor = 'var(--blue)'; });
|
|
zone.addEventListener('dragleave', function(){ zone.style.borderColor = 'var(--line)'; });
|
|
zone.addEventListener('drop', function(e){
|
|
e.preventDefault();
|
|
zone.style.borderColor = 'var(--line)';
|
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) fabStart(e.dataTransfer.files[0]);
|
|
});
|
|
})();
|
|
</script>
|
|
|
|
{{template "layout_end" .}}
|
|
{{end}}
|