Files
felhom-controller/controller/internal/web/templates/deploy.html
T
admin e66d4669d6
gates / gates (push) Failing after 47s
v0.315.0: one app page with four tabs; the guide the catalog can carry; no Raspberry Pi badge
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
2026-10-15 18:37:50 +02:00

545 lines
28 KiB
HTML

{{define "deploy"}}
{{template "layout_start" .}}
{{/* THE INSTALL WIZARD. v0.315.0: this page is the install flow ONLY. An installed app's settings
are the app page's „Beallitasok" tab (app_settings.html), which renders the same form fields
from the shared `app_deploy_fields` partial with .AlreadyDeployed true — so the two cannot
drift apart.
Vissza and Megsem go to the APP, not to the list: the household got here from the app's page
and that is where cancelling belongs. */}}
<div class="page-header">
<div style="display:flex;align-items:center;gap:.5rem">
<a href="/apps/{{.Meta.Slug}}" class="btn btn-sm btn-outline" data-install-back="1">{{T "deploy.vissza"}}</a>
<h2>{{.Meta.DisplayName}} — {{T "deploy.telepites"}}</h2>
</div>
<div style="display:flex;align-items:center;gap:.5rem">
<a href="/apps/{{.Meta.Slug}}" class="btn btn-sm btn-outline"><svg class="ico ico-sm"><use href="#i-info"/></svg> {{T "deploy.reszletek"}}</a>
</div>
</div>
<div class="deploy-container">
{{if .FlashSuccess}}<div class="flash flash-success">{{.FlashSuccess}}</div>{{end}}
{{if .FlashError}}<div class="flash flash-error">{{.FlashError}}</div>{{end}}
{{- if .KnownLoginLine}}<div class="flash flash-error" id="known-login-line">{{.KnownLoginLine}}</div>{{end}}
<div class="deploy-info">
<img class="deploy-logo" src="{{.LogoURL}}" alt="" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{.LogoPNGURL}}';}else if(this.dataset.fallback&&this.dataset.step==='1'){this.dataset.step='2';this.src=this.dataset.fallback;}else{this.onerror=null;this.style.visibility='hidden';}">
<div>
<h3>{{.Meta.DisplayName}}</h3>
{{if .Meta.Description}}<p>{{.Meta.Description}}</p>{{end}}
<div class="metarows">
{{if .Meta.Resources.MemRequest}}<span class="metarow"><svg class="ico"><use href="#i-memory-stick"/></svg><span class="mono">~{{.Meta.Resources.MemRequest}}</span></span>{{end}}
{{if .Meta.Resources.NeedsHDD}}<span class="metarow"><svg class="ico"><use href="#i-hard-drive"/></svg>{{T "common.hdd_szukseges"}}</span>{{end}}
{{if .Meta.Resources.HungarianUI}}<span class="metarow"><svg class="ico"><use href="#i-check"/></svg>{{T "common.magyar_felulet"}}</span>{{end}}
</div>
<a href="/apps/{{.Meta.Slug}}" class="btn btn-sm btn-outline" style="margin-top:0.5rem">
{{T "deploy.reszletes_leiras_kepernyokepek"}}
</a>
</div>
</div>
{{if and (not .AlreadyDeployed) .MemoryInfo}}
{{with .MemoryInfo}}
{{if .Available}}
<div class="memory-summary{{if .Blocked}} memory-blocked{{end}}">
{{if .Blocked}}
<div class="alert alert-error" style="margin-bottom:0">
{{T "deploy.nincs_eleg_memoria_foglalas_telepites"}}
</div>
{{else}}
<div class="memory-summary-header">
<span class="memory-summary-label">{{T "deploy.memoria"}}</span>
<span class="memory-summary-value">{{.AfterMB}} MB / {{.UsableMB}} MB ({{.Percent}}%)</span>
</div>
<div class="meter-track meter-track-stacked">
<div class="memory-bar-segment memory-bar-committed" style="width:{{.UsedPercent}}%" title="{{T "deploy.jelenlegi_hasznalat_mb"}}"></div>
<div class="memory-bar-segment memory-bar-new" style="width:{{subtract .Percent .UsedPercent}}%" title="{{$.Meta.DisplayName}}: +{{.NewRequestMB}} MB"></div>
</div>
<div class="memory-bar-legend">
<span class="memory-legend-item"><span class="memory-legend-dot memory-legend-committed"></span>{{T "deploy.jelenlegi_hasznalat_mb_2"}}</span>
<span class="memory-legend-item"><span class="memory-legend-dot memory-legend-new"></span>{{$.Meta.DisplayName}} (+{{.NewRequestMB}} MB)</span>
</div>
{{if .OvercommitWarn}}
<div class="alert alert-warning" style="margin-top:0.5rem;margin-bottom:0">
{{T "deploy.az_alkalmazasok_csucsterhelese_meghaladh"}}
</div>
{{end}}
{{end}}
</div>
{{end}}
{{end}}
{{end}}
<div id="kept-choice" class="kept-choice card" hidden></div>
<form id="deploy-form" class="deploy-form">
{{/* R-351 SCENARIO A — the values below came from this app's OWN backup, so a reinstall does
not ask the customer to remember what the backup already recorded. Stated, never silent:
a prefilled field whose origin is unexplained is indistinguishable from a default. */}}
{{if and (not .AlreadyDeployed) .RestoreHasRecord}}
<div class="alert alert-info" style="margin-bottom:1rem">
<svg class="ico ico-sm"><use href="#i-info"/></svg>
{{T "deploy.ennek_az_alkalmazasnak_van_korabbi"}}
{{if .RestoreRecordedAddress}}{{T "deploy.korabbi_webcim"}}{{end}}
{{if .RestoreRecordedDrive}}{{T "deploy.az_adatai_itt_voltak"}}{{end}}
{{T "deploy.ha_most_mashova_szeretned_telepiteni"}}
</div>
{{end}}
{{/* R-351 / Part 1 — WHERE THE DATA WILL LIVE, before the button is pressed. Measured
2026-08-21: 13 of 53 catalog templates declare a storage field; the other 40 have none and
their data goes to the system drive, which no screen said. This states it. It changes no
placement — that is a separate ruling and a separate session. */}}
{{if not .AlreadyDeployed}}
<div class="form-hint" style="margin-bottom:1rem">
{{if .RestoreRecordedDeclaresPath}}
{{T "deploy.az_alkalmazas_adatai_az_alabb"}}
{{else}}
{{T "deploy.hol_lesznek_az_adatok_ennel"}}
{{end}}
</div>
{{end}}
{{if .DockerBelowReserve}}
<div class="alert alert-warning" style="margin-bottom:1rem">
<svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> {{T "deploy.nincs_eleg_szabad_tarhely_a"}}
</div>
{{end}}
{{template "app_deploy_fields" .}}
{{if not .AlreadyDeployed}}
<div class="deploy-actions">
<button type="submit" class="btn btn-primary btn-lg"{{if .DockerBelowReserve}} disabled title="{{T "deploy.nincs_eleg_szabad_tarhely"}}"{{else if and .MemoryInfo (index .MemoryInfo "Blocked")}} disabled title="{{T "deploy.nincs_eleg_memoria"}}"{{end}}>{{T "deploy.telepites_inditasa"}}</button>
<a href="/apps/{{.Meta.Slug}}" class="btn btn-outline" data-install-cancel="1">{{T "common.megsem"}}</a>
</div>
{{end}}
</form>
<div id="deploy-progress" class="deploy-progress" style="display:none">
<h3>{{T "deploy.telepites_folyamatban"}}</h3>
<div class="deploy-steps">
<div class="deploy-step active" id="step-config">
<span class="step-icon"><svg class="ico"><use href="#i-rotate-cw"/></svg></span>
<span class="step-text">{{T "deploy.konfiguracio_mentese"}}</span>
</div>
<div class="deploy-step" id="step-containers">
<span class="step-icon"><svg class="ico"><use href="#i-rotate-cw"/></svg></span>
<span class="step-text">{{T "deploy.kontener_ek_inditasa"}}</span>
</div>
<div class="deploy-step" id="step-health">
<span class="step-icon"><svg class="ico"><use href="#i-rotate-cw"/></svg></span>
<span class="step-text">{{T "deploy.alkalmazas_inicializalasa"}}</span>
</div>
</div>
<div id="deploy-warning" class="alert alert-warning" style="display:none"></div>
<div id="deploy-result" style="display:none"></div>
<p class="deploy-elapsed" id="deploy-elapsed"></p>
</div>
</div>
<script>
var postDeployInfo = {
firstSteps: {{json .Meta.AppInfo.FirstSteps}},
defaultCreds: {{if .Meta.AfterInstall}}""{{else}}{{json .Meta.AppInfo.DefaultCreds}}{{end}},
docsURL: {{json .Meta.AppInfo.DocsURL}},
domain: {{json .Domain}},
displayName: {{json .Meta.DisplayName}},
deployFields: {{json .Meta.DeployFields}}
};
// showKeptChoice renders the two choices of `09` §3 decision 36. Every sentence arrives rendered in the
// reader's language from the server (textContent only — nothing is parsed as HTML).
function showKeptChoice(d, form) {
var box = document.getElementById('kept-choice');
box.textContent = '';
function el(tag, cls, text) { var x = document.createElement(tag); if (cls) x.className = cls; if (text) x.textContent = text; return x; }
box.appendChild(el('h3', 'kept-choice-title', d.title));
box.appendChild(el('p', 'kept-choice-body', d.body));
function option(label, desc, choice, enabled, extra) {
var o = el('div', 'kept-choice-option');
var b = el('button', enabled ? 'btn btn-primary' : 'btn btn-outline', label);
b.type = 'button';
b.disabled = !enabled;
b.setAttribute('data-kept-choice', choice);
b.addEventListener('click', function () { window.keptChoice = choice; box.hidden = true; form.requestSubmit(); });
o.appendChild(b);
o.appendChild(el('p', 'kept-choice-desc', desc));
if (extra) o.appendChild(el('p', 'kept-choice-note', extra));
return o;
}
box.appendChild(option(d.use_label, d.use_offered ? d.use_desc : d.use_off, 'use', d.use_offered, ''));
box.appendChild(option(d.fresh_label, d.fresh_desc, 'fresh', true, d.not_backed_up));
box.hidden = false;
box.scrollIntoView({behavior: 'smooth'});
}
function buildPostDeployCard(stackName) {
var subdomain = '';
var sdField = document.getElementById('field-SUBDOMAIN');
if (sdField) subdomain = sdField.value.trim();
if (!subdomain && '{{.Meta.Subdomain}}') subdomain = '{{.Meta.Subdomain}}';
var html = '';
// App link
if (subdomain && postDeployInfo.domain) {
var appURL = 'https://' + subdomain + '.' + postDeployInfo.domain;
html += '<div style="text-align:center;margin:1.5rem 0">' +
'<a href="' + appURL + '" target="_blank" class="btn btn-primary btn-lg">{{T "deploy.alkalmazas_megnyitasa"}}</a>' +
'</div>';
}
// First steps — with dynamic credential hint
if (postDeployInfo.firstSteps && postDeployInfo.firstSteps.length > 0) {
html += '<div class="app-info-card" style="margin-top:1rem"><h4>{{T "deploy.elso_lepesek"}}</h4><ol class="app-info-list">';
for (var i = 0; i < postDeployInfo.firstSteps.length; i++) {
var step = postDeployInfo.firstSteps[i].replace(/DOMAIN/g, postDeployInfo.domain);
html += '<li>' + step + '</li>';
}
html += '</ol></div>';
}
// Credentials from deploy fields (show actual values from form)
var credRows = '';
var hasUserCredFields = false;
if (postDeployInfo.deployFields) {
for (var i = 0; i < postDeployInfo.deployFields.length; i++) {
var f = postDeployInfo.deployFields[i];
// Show secrets, passwords, and username-like text fields
var isPassword = f.type === 'secret' || f.type === 'password';
var isUsername = false;
if (!isPassword && f.type === 'text') {
var ev = f.env_var.toUpperCase();
isUsername = ev.indexOf('USER') >= 0 || ev.indexOf('ADMIN') >= 0 || ev.indexOf('LOGIN') >= 0 || ev.indexOf('EMAIL') >= 0;
}
if (!isPassword && !isUsername) continue;
// Skip internal DB credentials
var evUp = f.env_var.toUpperCase();
if (evUp.indexOf('DB_PASSWORD') >= 0 || evUp.indexOf('MYSQL_ROOT') >= 0 || evUp.indexOf('SECRET_KEY') >= 0) continue;
if (isPassword || isUsername) hasUserCredFields = true;
// Read value from DOM
var el = document.getElementById('auto-field-' + f.env_var) || document.getElementById('field-' + f.env_var);
var val = el ? el.value : '';
if (!val) continue;
if (isPassword) {
var credId = 'postdeploy-cred-' + i;
credRows += '<tr><td style="padding:.25rem .75rem .25rem 0;color:var(--text-3);white-space:nowrap">' + f.label + '</td>' +
'<td style="padding:.25rem 0;display:flex;align-items:center;gap:.5rem">' +
'<code id="' + credId + '" style="font-family:var(--font-data);letter-spacing:1px">••••••••••••</code>' +
'<button type="button" class="btn btn-sm btn-outline" onclick="togglePostDeployCred(this,\'' + credId + '\',\'' + val.replace(/'/g, "\\'") + '\')" style="padding:.15rem .4rem;font-size:.75rem">{{T "deploy.megjelenites"}}</button>' +
'<button type="button" class="btn btn-sm btn-outline" onclick="copyPostDeployCred(this,\'' + val.replace(/'/g, "\\'") + '\')" style="padding:.15rem .4rem;font-size:.75rem">{{T "deploy.masolas"}}</button>' +
'</td></tr>';
} else {
credRows += '<tr><td style="padding:.25rem .75rem .25rem 0;color:var(--text-3);white-space:nowrap">' + f.label + '</td>' +
'<td style="padding:.25rem 0;font-family:var(--font-data);user-select:all">' + val + '</td></tr>';
}
}
}
if (credRows) {
html += '<div class="app-info-card" style="margin-top:1rem"><h4>{{T "deploy.bejelentkezes"}}</h4>' +
'<table style="margin:.5rem 0">' + credRows + '</table></div>';
} else if (postDeployInfo.defaultCreds) {
var creds = postDeployInfo.defaultCreds.replace(/DOMAIN/g, postDeployInfo.domain);
html += '<div class="app-info-card" style="margin-top:1rem"><h4>{{T "deploy.bejelentkezes"}}</h4>' +
'<p class="app-info-creds">' + creds + '</p></div>';
}
// Docs link
if (postDeployInfo.docsURL) {
html += '<div style="margin-top:1rem"><a href="' + postDeployInfo.docsURL + '" target="_blank" class="btn btn-sm btn-outline">{{T "deploy.dokumentacio"}}</a></div>';
}
// Action buttons
html += '<div style="display:flex;gap:.75rem;margin-top:1.5rem;flex-wrap:wrap">' +
'<a href="/apps/{{.Meta.Slug}}/settings" class="btn btn-outline">{{T "deploy.beallitasok_es_jelszavak_megtekintese"}}</a>' +
'<a href="/stacks" class="btn btn-sm btn-outline">{{T "deploy.alkalmazasok"}}</a>' +
'</div>';
return html;
}
function checkStorageSpace(sel) {
var opt = sel.options[sel.selectedIndex];
var warn = document.getElementById('storage-space-warn');
if (!warn) return;
var freePct = parseFloat(opt.getAttribute('data-free-percent') || '100');
warn.style.display = freePct < 20 ? 'block' : 'none';
}
// Check on page load
document.addEventListener('DOMContentLoaded', function() {
var sel = document.querySelector('select[onchange="checkStorageSpace(this)"]');
if (sel) checkStorageSpace(sel);
});
// R-254 site two: on an already-deployed app the value is NOT in this page — it is fetched on
// demand, and the server records the act. Nothing caches it between presses.
// R-709 (v0.280.0): an installed app's `type: password` value, fetched on demand like a secret.
function toggleAutoField(fieldId, btn) {
var el = document.getElementById(fieldId);
if (!el) return;
el.type = el.type === 'password' ? 'text' : 'password';
btn.textContent = el.type === 'password' ? '{{T "deploy.megjelenites"}}' : '{{T "deploy.elrejtes"}}';
}
// spec is the field's `generate:` (e.g. "password:24:special"); empty = 16 letters and digits. With
// ":special" (v0.280.0) the password carries a lower, an upper, a digit and one of SPECIAL — for an app whose
// own policy demands it (calibre-web). SPECIAL matches internal/stacks generateValue.
function generatePassword(fieldId, confirmFieldId, spec) {
const letters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
const SPECIAL = '-_.!@#%+=';
var parts = (spec || '').split(':');
var n = parseInt(parts[1], 10);
if (parts[0] !== 'password' || !(n >= 12 && n <= 64)) { n = 16; }
var special = parts[2] === 'special';
var chars = special ? letters + SPECIAL : letters;
let pass = '';
for (;;) {
pass = '';
const arr = new Uint32Array(n);
crypto.getRandomValues(arr);
for (let i = 0; i < n; i++) {
pass += chars[arr[i] % chars.length];
}
if (!special || (/[a-z]/.test(pass) && /[A-Z]/.test(pass) && /[0-9]/.test(pass) && /[-_.!@#%+=]/.test(pass) && /^[A-Za-z0-9]/.test(pass))) {
break;
}
}
document.getElementById(fieldId).value = pass;
if (confirmFieldId) {
var ce = document.getElementById(confirmFieldId);
if (ce) ce.value = pass;
}
}
function togglePasswordField(fieldId, confirmFieldId, btn) {
var el = document.getElementById(fieldId);
if (!el) return;
var newType = el.type === 'password' ? 'text' : 'password';
el.type = newType;
var ce = document.getElementById(confirmFieldId);
if (ce) ce.type = newType;
btn.title = newType === 'password' ? '{{T "deploy.megjelenites"}}' : '{{T "deploy.elrejtes"}}';
}
function togglePostDeployCred(btn, credId, val) {
var el = document.getElementById(credId);
if (!el) return;
if (el.textContent === val) {
el.textContent = '••••••••••••';
btn.textContent = '{{T "deploy.megjelenites"}}';
} else {
el.textContent = val;
btn.textContent = '{{T "deploy.elrejtes"}}';
}
}
function copyPostDeployCred(btn, val) {
if (navigator.clipboard) {
navigator.clipboard.writeText(val).then(function() {
var orig = btn.textContent;
btn.textContent = '{{T "deploy.masolva"}}';
setTimeout(function() { btn.textContent = orig; }, 1500);
});
}
}
document.getElementById('deploy-form').addEventListener('submit', async function(e) {
e.preventDefault();
// Client-side validation: check all password fields are filled
const passwordFields = e.target.querySelectorAll('input[data-field-type="password"]');
for (const pf of passwordFields) {
if (!pf.disabled && pf.value.trim() === '') {
const label = pf.closest('.form-group').querySelector('label').textContent.trim();
showAlert('{{T "deploy.kotelezo_mezo"}} ' + label + '{{T "deploy.nhasznalja_a_generalas_gombot_vagy"}}');
pf.focus();
return;
}
}
// Client-side validation: check password confirmation matches
const confirmInputs = e.target.querySelectorAll('input[data-confirm-for]');
for (const ci of confirmInputs) {
const mainEl = document.getElementById(ci.getAttribute('data-confirm-for'));
if (mainEl && !mainEl.disabled && ci.value !== mainEl.value) {
const label = mainEl.closest('.form-group').querySelector('label').textContent.trim();
showAlert('{{T "deploy.a_ket_jelszo_nem_egyezik"}} ' + label);
ci.focus();
return;
}
}
// Client-side validation: check subdomain format
const subdomainField = e.target.querySelector('.subdomain-input');
if (subdomainField && !subdomainField.disabled) {
const sd = subdomainField.value.trim().toLowerCase();
if (!sd || !/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(sd)) {
showAlert('{{T "deploy.az_aldomain_csak_kisbetuket_szamokat"}}');
subdomainField.focus();
return;
}
}
// Client-side validation: check all required fields
const requiredFields = e.target.querySelectorAll('input[required], select[required]');
for (const rf of requiredFields) {
if (!rf.disabled && rf.value.trim() === '') {
const label = rf.closest('.form-group').querySelector('label').textContent.trim();
showAlert('{{T "deploy.kotelezo_mezo"}} ' + label);
rf.focus();
return;
}
}
const btn = e.target.querySelector('[type=submit]');
btn.disabled = true;
btn.textContent = '{{T "deploy.telepites_folyamatban"}}';
const values = {};
const inputs = e.target.querySelectorAll('input, select');
inputs.forEach(function(el) {
if (el.name && !el.disabled) {
if (el.type === 'checkbox') {
values[el.name] = el.checked ? 'true' : 'false';
} else {
values[el.name] = el.value;
}
}
});
var stackName = '{{.Stack.Name}}';
var progressEl = document.getElementById('deploy-progress');
var formEl = document.getElementById('deploy-form');
var stepConfig = document.getElementById('step-config');
var stepContainers = document.getElementById('step-containers');
var stepHealth = document.getElementById('step-health');
var warningEl = document.getElementById('deploy-warning');
var resultEl = document.getElementById('deploy-result');
var elapsedEl = document.getElementById('deploy-elapsed');
function setStep(el, status, text) {
el.className = 'deploy-step ' + status;
if (text) el.querySelector('.step-text').textContent = text;
var icon = el.querySelector('.step-icon');
if (status === 'done') icon.innerHTML = '<svg class="ico"><use href="#i-check"/></svg>';
else if (status === 'error') icon.innerHTML = '<svg class="ico"><use href="#i-x"/></svg>';
else if (status === 'warn') icon.innerHTML = '<svg class="ico"><use href="#i-triangle-alert"/></svg>';
else if (status === 'active') icon.innerHTML = '<svg class="ico ico-spin"><use href="#i-rotate-cw"/></svg>';
}
// Phase 1: Deploy request
try {
var resp = await fetch('/api/stacks/' + stackName + '/deploy', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({values: values, kept_data: window.keptChoice || ''})
});
var data = await resp.json();
// `09` §3 decision 36: the drive already holds this app's old data — the household chooses.
if (!data.ok && data.data && data.data.code === 'kept_data_choice') {
showKeptChoice(data.data, e.target);
btn.textContent = '{{T "deploy.telepites_inditasa"}}';
btn.disabled = false;
return;
}
if (!data.ok) {
showAlert('{{T "common.hiba_kettospont"}} ' + data.error);
btn.textContent = '{{T "deploy.telepites_inditasa"}}';
btn.disabled = false;
return;
}
// Deploy API returned success — switch to progress view
formEl.style.display = 'none';
progressEl.style.display = 'block';
setStep(stepConfig, 'done', '{{T "deploy.konfiguracio_mentve"}}');
setStep(stepContainers, 'active', '{{T "deploy.kontener_ek_inditasa"}}');
if (data.data && data.data.warning) {
warningEl.textContent = data.data.warning;
warningEl.style.display = 'block';
}
// Phase 2: Poll stack status
var startTime = Date.now();
var pollTimeout = window.keptChoice === 'use' ? 900000 : 120000; // a load restores a backup first
var pollTimer = setInterval(async function() {
var elapsed = Math.round((Date.now() - startTime) / 1000);
elapsedEl.textContent = elapsed + ' {{T "deploy.masodperce"}}';
if (Date.now() - startTime > pollTimeout) {
clearInterval(pollTimer);
setStep(stepHealth, 'warn', '{{T "deploy.idotullepes_az_alkalmazas_meg_indulhat"}}');
resultEl.innerHTML = '<div class="alert alert-warning" style="margin-top:1rem">' +
'{{T "deploy.a_telepites_idotullepesbe_futott_az"}}' +
'</div>' + buildPostDeployCard(stackName);
resultEl.style.display = 'block';
return;
}
try {
var sr = await fetch('/api/stacks/' + stackName);
var sd = await sr.json();
if (!sd.ok || !sd.data) return;
var state = sd.data.state;
var deployError = sd.data.deploy_error;
if (deployError) {
// Async compose-up failed
clearInterval(pollTimer);
setStep(stepContainers, 'error', '{{T "deploy.telepites_sikertelen"}}');
setStep(stepHealth, 'error');
progressEl.querySelector('h3').textContent = '{{T "deploy.telepites_sikertelen"}}';
resultEl.innerHTML = '<div class="alert alert-error" style="margin-top:1rem">' +
'{{T "deploy.a_telepites_nem_sikerult"}} ' + deployError +
'</div><a href="/apps/{{.Meta.Slug}}/logs" class="btn btn-outline" style="margin-top:.75rem">{{T "deploy.naplok_megtekintese"}}</a>' +
' <a href="/stacks" class="btn btn-primary" style="margin-top:.75rem">{{T "deploy.alkalmazasok_2"}}</a>';
resultEl.style.display = 'block';
} else if (state === 'deploying') {
// Compose up in progress (pulling images, creating containers)
setStep(stepContainers, 'active', '{{T "deploy.kepek_letoltese_kontenerek_inditasa"}}');
} else if (state === 'running') {
clearInterval(pollTimer);
setStep(stepContainers, 'done', '{{T "deploy.kontenerek_elindultak"}}');
setStep(stepHealth, 'done', '{{T "deploy.alkalmazas_kesz"}}');
progressEl.querySelector('h3').textContent = '{{T "deploy.telepites_sikeres"}}';
resultEl.innerHTML = '<div class="alert alert-info" style="margin-top:1rem">' +
'{{T "deploy.az_alkalmazas_sikeresen_telepitve_es"}}' +
'</div>' + buildPostDeployCard(stackName);
resultEl.style.display = 'block';
} else if (state === 'starting') {
setStep(stepContainers, 'done', '{{T "deploy.kontenerek_elindultak"}}');
setStep(stepHealth, 'active', '{{T "deploy.alkalmazas_inicializalasa"}}');
} else if (state === 'unhealthy') {
clearInterval(pollTimer);
setStep(stepContainers, 'done', '{{T "deploy.kontenerek_elindultak"}}');
setStep(stepHealth, 'warn', '{{T "deploy.allapotjelzo_meg_inicializal"}}');
progressEl.querySelector('h3').textContent = '{{T "deploy.telepites_sikeres"}}';
resultEl.innerHTML = '<div class="alert alert-warning" style="margin-top:1rem">' +
'{{T "deploy.az_alkalmazas_elindult_de_meg"}} ' +
'{{T "deploy.ez_normalis_az_elso_percekben"}}' +
'</div>' + buildPostDeployCard(stackName);
resultEl.style.display = 'block';
} else if (state === 'exited' || state === 'stopped') {
clearInterval(pollTimer);
setStep(stepContainers, 'error', '{{T "deploy.a_kontener_leallt"}}');
setStep(stepHealth, 'error');
progressEl.querySelector('h3').textContent = '{{T "deploy.telepites_sikertelen"}}';
resultEl.innerHTML = '<div class="alert alert-error" style="margin-top:1rem">' +
'{{T "deploy.a_kontener_leallt_ellenorizze_a"}}' +
'</div><a href="/apps/{{.Meta.Slug}}/logs" class="btn btn-outline" style="margin-top:.75rem">{{T "deploy.naplok_megtekintese"}}</a>' +
' <a href="/stacks" class="btn btn-primary" style="margin-top:.75rem">{{T "deploy.alkalmazasok_2"}}</a>';
resultEl.style.display = 'block';
}
} catch(pollErr) {}
}, 3000);
} catch (err) {
showAlert('{{T "deploy.halozati_hiba_2"}} ' + err.message);
btn.textContent = '{{T "deploy.telepites_inditasa"}}';
btn.disabled = false;
}
});
</script>
{{template "layout_end" .}}
{{end}}