Files
felhom-controller/controller/internal/web/templates/deploy.html
T
admin f94543ee5c
gates / gates (push) Successful in 10s
v0.217.0: prefill from the app's own backup, where-the-data-goes on deploy, bounded inventory fan-out
Completes R-351 and ships R-352's visibility half. Gates 11/11 OK, suite 28 packages ok,
go vet clean, -race clean on the changed package - all run and read BEFORE this commit.

PART 2 SCENARIO A - the deploy page prefills the address and data folder from the app's OWN
backup. backup.RecordedUnitForStack scans every readable namespace root (the app is NOT
installed in this case, so there is no own drive to ask) and reads manifest.json plus the
captured compose/app.yaml. Local file reads only: no network, no restic, no restore.
RecordedAddress.Known() requires BOTH halves on purpose - an absent SUBDOMAIN makes the live
deploy path substitute the CATALOG default (stacks/deploy.go:88-90), and offering that back as
"what your backup says" would be a fabricated fact. The prefill is labelled as coming from the
backup and stays editable: a memory, not a lock.

PART 1 VISIBILITY (R-352) - the deploy page now states where the app's data will live before
the button is pressed. Measured 2026-08-21: 13 of 53 catalogue templates declare a storage
field; the other 40 have none and their data goes to the system drive, which no screen said.
Metadata.HasDeployField answers "does this app have somewhere to PUT a recorded value?" - for
the 40-class a recorded placement is a fact to state, never a value written into a field that
does not exist. NO PLACEMENT CHANGED. NOTHING MIGRATED. The rest is a filed specification.

PART 4 - measured before theorising, on the live off-site target:
  snapshots --json 2605 ms once; stats 2697 ms PER APP, sequential, 5 app tags
  => 2605 + 5*2697 = ~16.1 s, matching the reported ten-to-fifteen seconds.
The cause is the shape already on file, so the per-app size calls now run concurrently,
BOUNDED TO 4. The bound is the safety property, not the speed one: the repository is a Hetzner
Storage Box with a session cap, and a refused size call returns SizeBytes 0 - a silent
UNDER-REPORT of the customer's data rather than a visible failure. Peak-in-flight is asserted.
OffsiteInventoryList had no test at all before this.

TEMPLATE SAFETY - every Restore* key is set UNCONDITIONALLY in the deploy handler, because a
template doing index/eq against an undefined key errors at RENDER time: green build, green vet,
green suite, 500 on the page. Four render tests, one per branch, because the existing deploy
render test only renders AutoFields and never reaches these blocks.

RED-PROOFS, mutation asserted applied then reverted to 0:
  A   three template guards dropped (count asserted 3) -> the blank form returned
  P4  inventorySizeConcurrency = 1 -> "peak in flight was 1", elapsed 282ms = sequential

DOCS: CHANGELOG v0.217.0 (MinAgent 0.129.0 unchanged), CONTEXT (the restore's own memory +
what is next), controller/README.md (Backup System), REUSE.md (4 new rows), REPORT.md
overwritten - the previous REPORT preserved to audits/REPORT-v0.216.0-2026-08-14.md first.

NOT fixed here, filed as R-353 and named the next session's first item: a restore whose unit
carries no db_dumps and no volume_dumps still reports a bare completion.
2026-08-21 21:29:01 +02:00

1109 lines
55 KiB
HTML

{{define "deploy"}}
{{template "layout_start" .}}
<div class="page-header">
<div style="display:flex;align-items:center;gap:.5rem">
<a href="/stacks" class="btn btn-sm btn-outline">← Vissza</a>
<h2>{{.Meta.DisplayName}} — {{if .AlreadyDeployed}}Beállítások{{else}}Telepítés{{end}}</h2>
</div>
<div style="display:flex;align-items:center;gap:.5rem">
{{if .AlreadyDeployed}}
{{if isOperational .Stack.State}}
<button class="btn btn-sm btn-warning" onclick="stackAction(event, '{{.Stack.Name}}', 'restart')">Újraindítás</button>
<button class="btn btn-sm btn-danger" onclick="stackAction(event, '{{.Stack.Name}}', 'stop')">Leállítás</button>
{{else}}
<button class="btn btn-sm btn-success" onclick="stackAction(event, '{{.Stack.Name}}', 'start')">Indítás</button>
{{end}}
{{if and (isOperational .Stack.State) .EffectiveSubdomain}}
<a href="https://{{.EffectiveSubdomain}}.{{.Domain}}{{.Meta.OpenPath}}" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>
{{end}}
{{end}}
<a href="/apps/{{.Meta.Slug}}" class="btn btn-sm btn-outline"><svg class="ico ico-sm"><use href="#i-info"/></svg> Részletek</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}}
<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.PiCompatible}}<span class="metarow"><svg class="ico"><use href="#i-check"/></svg>Pi kompatibilis</span>{{end}}
{{if .Meta.Resources.NeedsHDD}}<span class="metarow"><svg class="ico"><use href="#i-hard-drive"/></svg>HDD szükséges</span>{{end}}
{{if .Meta.Resources.HungarianUI}}<span class="metarow"><svg class="ico"><use href="#i-check"/></svg>Magyar felület</span>{{end}}
</div>
<a href="/apps/{{.Meta.Slug}}" class="btn btn-sm btn-outline" style="margin-top:0.5rem">
Részletes leírás, képernyőképek
</a>
</div>
</div>
{{if .AlreadyDeployed}}
<div class="alert alert-info">
Ez az alkalmazás már telepítve van. Az alábbi beállítások csak olvashatók.
</div>
{{if .StorageInfo}}
<div class="deploy-storage-info">
<h4>Adattárolás</h4>
<div class="settings-grid">
<div class="settings-row">
<span class="settings-label">Tárhely</span>
<span class="settings-value">{{.StorageInfo.Label}} <span class="mono" style="color:var(--text-2)">({{.StorageInfo.Path}})</span></span>
</div>
{{if .StorageInfo.DataSizeHuman}}
<div class="settings-row">
<span class="settings-label">Adatméret</span>
<span class="settings-value mono">{{.StorageInfo.DataSizeHuman}}</span>
</div>
{{end}}
{{if .StorageInfo.FreeHuman}}
<div class="settings-row">
<span class="settings-label">Szabad hely</span>
<span class="settings-value mono">{{.StorageInfo.FreeHuman}} ({{printf "%.0f" .StorageInfo.FreePercent}}% szabad)</span>
</div>
{{end}}
</div>
{{if .OtherStoragePaths}}
<span class="btn btn-sm btn-outline" style="margin-top:.75rem;opacity:.45;cursor:not-allowed" title="Hamarosan">
<svg class="ico ico-sm"><use href="#i-upload"/></svg> Mozgatás másik tárolóra
</span>
{{end}}
</div>
{{end}}
{{if .StaleData}}
<div class="deploy-stale-data">
<h4>Korábbi adatok</h4>
<p class="form-hint" style="margin-bottom:1rem">
Az alkalmazás adatainak másolata megtalálható egy másik tárolón is.
Ez általában áthelyezés után marad hátra.
</p>
{{range .StaleData}}
<div class="stale-data-item">
<div class="settings-grid" style="margin-bottom:.75rem">
<div class="settings-row">
<span class="settings-label">Tárhely</span>
<span class="settings-value">{{.Label}} <span class="mono" style="color:var(--text-2)">({{.Path}})</span></span>
</div>
<div class="settings-row">
<span class="settings-label">Méret</span>
<span class="settings-value mono">{{.SizeHuman}}</span>
</div>
<div class="settings-row">
<span class="settings-label">Mappák</span>
<span class="settings-value mono" style="font-size:.85rem">{{range .Mounts}}{{.}}<br>{{end}}</span>
</div>
</div>
<button class="btn btn-sm btn-danger" onclick="deleteStaleData('{{$.Meta.Slug}}', '{{.Path}}', this)">
Korábbi adatok törlése
</button>
</div>
{{end}}
</div>
{{end}}
{{end}}
{{if .AlreadyDeployed}}
<div class="deploy-cross-drive">
<h4>Biztonsági mentés</h4>
<div class="cross-drive-nightly">
<span class="form-hint" style="display:block;margin-top:.25rem">
Az alkalmazás adatbázisa és Docker kötetei automatikusan bekerülnek az éjszakai biztonsági mentésbe.
<a href="/backups" style="color:var(--blue)">Mentési állapot →</a> · <a href="/stacks/{{.Stack.Name}}/backup" style="color:var(--blue)">Mentési beállítások →</a>
</span>
</div>
</div>
{{end}}
{{if .HasIntegrations}}
<div class="app-optional-config">
<h3>Integrációk</h3>
<p class="config-group-desc">
Más telepített alkalmazásokkal való összekapcsolás. Az integráció automatikusan felfüggesztődik, ha bármelyik alkalmazás leáll, és újraaktiválódik indításkor.
</p>
{{range .Integrations}}
<div class="config-field" style="display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.75rem 0;border-bottom:1px solid var(--line)">
<div style="flex:1">
<strong>{{.Label}}</strong>
<p class="config-field-help" style="margin:0">{{.Description}}</p>
{{if not .TargetDeployed}}
<span class="tag tag-off" style="margin-top:.25rem"><span class="dot"></span>Nincs telepítve</span>
{{else if not .TargetRunning}}
<span class="tag tag-neutral" style="margin-top:.25rem"><span class="dot"></span>Célalkalmazás leállítva</span>
{{else if eq .Status "error"}}
<span class="tag tag-crit" style="margin-top:.25rem"><span class="dot"></span>Hiba</span>
{{else if .Enabled}}
<span class="tag tag-run" style="margin-top:.25rem"><span class="dot"></span>Aktív</span>
{{end}}
</div>
<label class="toggle">
<input type="checkbox"
{{if .Enabled}}checked{{end}}
{{if not .TargetDeployed}}disabled title="A célalkalmazás nincs telepítve"{{end}}
{{if and .TargetDeployed (not .TargetRunning)}}disabled title="A célalkalmazás nem fut"{{end}}
onchange="toggleIntegration('{{$.Stack.Name}}', '{{.Target}}', this.checked, this)">
<span class="toggle-label"></span>
</label>
</div>
{{end}}
</div>
<script>
async function toggleIntegration(provider, target, enable, checkbox) {
checkbox.disabled = true;
try {
var resp = await fetch('/api/integrations/' + provider + '/' + target, {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({enabled: enable})
});
var data = await resp.json();
if (!data.ok) {
checkbox.checked = !enable;
showAlert(data.error || 'Hiba történt');
} else {
setTimeout(function(){ location.reload(); }, 500);
return;
}
} catch(err) {
checkbox.checked = !enable;
showAlert('Hálózati hiba');
}
checkbox.disabled = false;
}
</script>
{{end}}
{{if .GeoGlobalEnabled}}
<div class="app-optional-config">
<h3>Földrajzi korlátozás</h3>
<p class="config-group-desc">
Az alkalmazás egyéni országkorlátozás nélkül a globális beállítást követi.
</p>
<label class="toggle" style="margin-bottom:1rem">
<input type="checkbox" id="app-geo-override-toggle"
{{if .GeoAppOverride}}checked{{end}}
onchange="toggleAppGeoOverride(this.checked)">
<span class="toggle-label">Egyéni országkorlátozás</span>
</label>
<div id="app-geo-override-details" {{if not .GeoAppOverride}}style="display:none"{{end}}>
<div class="form-group">
<label>Engedélyezett országok ehhez az alkalmazáshoz</label>
<div class="geo-country-selector">
<input type="text" id="app-geo-search" class="form-control config-input"
placeholder="Ország keresése..." autocomplete="off"
oninput="filterAppGeoCountries(this.value)"
onfocus="showAppGeoList()"
onblur="setTimeout(function(){hideAppGeoList()},200)">
<div class="geo-country-list" id="app-geo-country-list"></div>
</div>
<div class="geo-selected-tags" id="app-geo-tags"></div>
</div>
<div class="config-actions">
<button class="btn btn-primary" onclick="saveAppGeoOverride()">Mentés</button>
<span id="app-geo-status" class="config-save-status"></span>
</div>
</div>
</div>
<script>
(function(){
var allCountries = [];
var appGeoCountries = {{json .GeoAppOverrideCountries}};
var stackName = '{{.Stack.Name}}';
function loadCountries(cb) {
if (allCountries.length > 0) { cb(); return; }
fetch('/api/geo/countries', {headers: csrfHeaders()})
.then(function(r){return r.json()})
.then(function(d){ if(d.ok) allCountries = d.data; cb(); })
.catch(function(){ cb(); });
}
window.toggleAppGeoOverride = function(enabled) {
document.getElementById('app-geo-override-details').style.display = enabled ? '' : 'none';
if (enabled) {
if (!appGeoCountries || appGeoCountries.length === 0) {
appGeoCountries = {{json .GeoGlobalCountries}};
}
loadCountries(renderAppGeoTags);
} else {
fetch('/api/stacks/' + stackName + '/geo/override', {method:'DELETE', headers:csrfHeaders()});
}
};
window.showAppGeoList = function() { loadCountries(function(){ filterAppGeoCountries(''); }); };
window.hideAppGeoList = function() { document.getElementById('app-geo-country-list').style.display='none'; };
window.filterAppGeoCountries = function(q) {
var list = document.getElementById('app-geo-country-list');
q = q.toLowerCase();
var html = ''; var count = 0;
for (var i = 0; i < allCountries.length && count < 15; i++) {
var c = allCountries[i];
if (appGeoCountries.indexOf(c.code) >= 0) continue;
if (q && c.name.toLowerCase().indexOf(q) < 0 && c.code.toLowerCase().indexOf(q) < 0) continue;
html += '<div class="geo-country-option" onmousedown="addAppGeoCountry(\'' + c.code + '\')">'
+ esc(c.name) + ' <small>(' + c.code + ')</small></div>';
count++;
}
list.innerHTML = html || '<div class="geo-country-option" style="opacity:.5">Nincs találat</div>';
list.style.display = (count > 0 || q) ? '' : 'none';
};
window.addAppGeoCountry = function(code) {
if (appGeoCountries.indexOf(code) >= 0) return;
appGeoCountries.push(code);
renderAppGeoTags();
document.getElementById('app-geo-search').value = '';
hideAppGeoList();
};
window.removeAppGeoCountry = function(code) {
appGeoCountries = appGeoCountries.filter(function(c){return c !== code});
renderAppGeoTags();
};
function renderAppGeoTags() {
var el = document.getElementById('app-geo-tags');
var html = '';
for (var i = 0; i < appGeoCountries.length; i++) {
var code = appGeoCountries[i];
var name = code;
for (var j = 0; j < allCountries.length; j++) {
if (allCountries[j].code === code) { name = allCountries[j].name; break; }
}
html += '<span class="geo-tag">' + esc(name) + ' (' + code + ') '
+ '<span class="geo-tag-remove" onclick="removeAppGeoCountry(\'' + code + '\')">&times;</span></span>';
}
el.innerHTML = html;
}
window.saveAppGeoOverride = function() {
var status = document.getElementById('app-geo-status');
fetch('/api/stacks/' + stackName + '/geo/override', {
method: 'POST',
headers: Object.assign({'Content-Type':'application/json'}, csrfHeaders()),
body: JSON.stringify({allowed_countries: appGeoCountries})
})
.then(function(r){return r.json()})
.then(function(d){
status.textContent = d.ok ? (d.message || 'Mentve') : (d.error || 'Hiba');
status.className = 'config-save-status ' + (d.ok ? 'config-save-ok' : 'config-save-err');
setTimeout(function(){ status.textContent=''; }, 5000);
})
.catch(function(){
status.textContent = 'Hálózati hiba';
status.className = 'config-save-status config-save-err';
});
};
function esc(s) { var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
if (document.getElementById('app-geo-override-toggle') && document.getElementById('app-geo-override-toggle').checked) {
loadCountries(renderAppGeoTags);
}
})();
</script>
{{end}}
{{if .HasOptionalConfig}}
<div class="app-optional-config">
<h3>Opcionális beállítások</h3>
{{range .OptionalConfig}}
<div class="config-group">
<h4>{{.Group}}</h4>
{{if .Description}}<p class="config-group-desc">{{.Description}}</p>{{end}}
<div class="config-fields">
{{range .Fields}}
<div class="config-field">
<label for="opt-{{.EnvVar}}">{{.Label}}</label>
{{if .HelpText}}<p class="config-field-help">{{.HelpText}}</p>{{end}}
{{if .HelpURL}}<p class="config-field-help"><a href="{{.HelpURL}}" target="_blank">Regisztrációs útmutató ↗</a></p>{{end}}
<input type="{{if eq .Type "secret_input"}}password{{else}}text{{end}}"
id="opt-{{.EnvVar}}"
name="{{.EnvVar}}"
class="config-input"
value="{{index $.CurrentValues .EnvVar}}"
placeholder="{{.Label}}"
autocomplete="off">
</div>
{{end}}
</div>
</div>
{{end}}
<div class="config-actions">
<button class="btn btn-primary" id="save-optional-config" onclick="saveOptionalConfig('{{.Stack.Name}}')">
Mentés
</button>
<span id="config-save-status" class="config-save-status"></span>
</div>
</div>
<script>
async function saveOptionalConfig(stackName) {
var btn = document.getElementById('save-optional-config');
var status = document.getElementById('config-save-status');
var inputs = document.querySelectorAll('.config-input');
var values = {};
inputs.forEach(function(input) {
values[input.name] = input.value;
});
btn.disabled = true;
btn.textContent = 'Mentés...';
status.textContent = '';
status.className = 'config-save-status';
try {
var resp = await fetch('/api/stacks/' + stackName + '/optional-config', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({values: values})
});
var data = await resp.json();
if (data.ok) {
status.textContent = (data.message || 'Mentve');
status.className = 'config-save-status config-save-ok';
} else {
status.textContent = (data.error || 'Hiba');
status.className = 'config-save-status config-save-err';
}
} catch(err) {
status.textContent = 'Hálózati hiba';
status.className = 'config-save-status config-save-err';
}
btn.disabled = false;
btn.textContent = 'Mentés';
setTimeout(function() { status.textContent = ''; }, 5000);
}
</script>
{{end}}
{{if .AppEmailSupported}}
<div class="app-optional-config">
<h3>Email-küldés</h3>
<p class="config-group-desc">
Ez az alkalmazás tud emailt küldeni (pl. jelszó-visszaállítás, meghívók) a Felhom-on keresztül,
külön szolgáltató beállítása nélkül. A feladó címe: <strong>{{.AppEmailFromAddress}}</strong>.
</p>
{{if not .AppEmailGlobalOn}}
<p class="alert alert-warning" style="margin-bottom:1rem">
Az alkalmazás-email jelenleg ki van kapcsolva globálisan. Kapcsold be a
<a href="/settings">Beállítások</a> oldalon, hogy itt is működjön.
</p>
{{end}}
<form method="POST" action="/stacks/{{.Stack.Name}}/app-email">
{{$.CSRFField}}
<label style="display:flex;align-items:center;gap:.5rem;margin-bottom:1rem">
<input type="checkbox" name="app_email_enabled" value="on" {{if .AppEmailAppOn}}checked{{end}}>
Email-küldés engedélyezése ennél az alkalmazásnál
</label>
<div class="config-actions">
<button class="btn btn-primary" type="submit">Mentés</button>
<span class="config-group-desc" style="margin-left:.5rem">A mentés újraindítja az alkalmazást.</span>
</div>
</form>
</div>
{{end}}
{{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">
Nincs elég memória! Foglalás telepítés után: {{.AfterMB}} MB / {{.UsableMB}} MB
</div>
{{else}}
<div class="memory-summary-header">
<span class="memory-summary-label">Memória</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="Jelenlegi használat: {{.UsedMB}} 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>Jelenlegi használat ({{.UsedMB}} MB)</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">
Az alkalmazások csúcsterhelése meghaladhatja a rendelkezésre álló memóriát.
Normál használat mellett ez nem okoz problémát.
</div>
{{end}}
{{end}}
</div>
{{end}}
{{end}}
{{end}}
<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>
Ennek az alkalmazásnak van korábbi mentése, ezért az alábbi mezőket a saját mentése alapján töltöttük ki.
{{if .RestoreRecordedAddress}}Korábbi webcím: <strong>{{.RestoreRecordedAddress}}</strong>.{{end}}
{{if .RestoreRecordedDrive}}Az adatai itt voltak: <strong>{{.RestoreRecordedDrive}}</strong>.{{end}}
Ha most máshová szeretnéd telepíteni, nyugodtan átírhatod — a visszaállítás előtt jelezni fogjuk az eltérést.
</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}}
Az alkalmazás adatai az alább kiválasztott adatmeghajtóra kerülnek.
{{else}}
<strong>Hol lesznek az adatok:</strong> ennél az alkalmazásnál nincs külön adatmeghajtó-választás,
ezért az adatai a rendszermeghajtóra kerülnek (<code>{{.SystemDataPath}}</code>). A mentései így is elkészülnek.
{{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> Nincs elég szabad tárhely a telepítéshez. Jelenleg {{.DockerFreeHuman}} szabad, és a rendszer
{{.DockerReserveHuman}} tartalékot tart fenn az alapszolgáltatások (vezérlő, proxy) védelmében.
A telepítés ezért átmenetileg le van tiltva — szabadítson fel helyet, vagy bővítse a tárhelyet.
</div>
{{end}}
{{if .AutoFields}}
<div class="form-section">
<h4>Automatikusan generált értékek</h4>
{{if .AlreadyDeployed}}
<p class="form-section-desc">Ezek az értékek automatikusan jöttek létre a telepítéskor.</p>
{{else}}
<p class="form-section-desc">Ezek az értékek a telepítéssel együtt mentésre kerülnek. Jegyezze fel a szükséges jelszavakat!</p>
{{end}}
{{$autoValues := .AutoFieldValues}}
{{$stackName := .Stack.Name}}
{{$isDeployed := .AlreadyDeployed}}
{{range .AutoFields}}
{{$val := index $autoValues .EnvVar}}
<div class="form-group form-group-auto">
<label>{{.Label}} {{if eq .Type "secret"}}<span class="auto-generated-badge"><svg class="ico ico-sm"><use href="#i-check"/></svg> Automatikusan generálva</span>{{end}}</label>
{{if $val}}
{{if eq .Type "secret"}}
{{if $isDeployed}}
<!-- R-254 site two: on an ALREADY-DEPLOYED app nothing is being submitted, so there is
no reason for the value to be in this page at all. It is fetched on demand. The
pre-deploy branch below is different and deliberate — the form must carry what it
submits, so the saved value is the one the customer was shown (README §318). -->
<div class="input-with-button">
<input type="password" id="auto-field-{{.EnvVar}}" class="form-control" value="" placeholder="••••••••••••" readonly>
<button type="button" class="btn btn-sm btn-outline" onclick="revealAutoField('{{$stackName}}','{{.EnvVar}}', this)">Megjelenítés</button>
</div>
{{else}}
<div class="input-with-button">
<input type="password" id="auto-field-{{.EnvVar}}" class="form-control" value="{{$val}}" readonly>
<button type="button" class="btn btn-sm btn-outline" onclick="toggleAutoField('auto-field-{{.EnvVar}}', this)">Megjelenítés</button>
</div>
{{end}}
{{else}}
<input type="text" id="auto-field-{{.EnvVar}}" class="form-control" value="{{$val}}" readonly>
{{end}}
{{if and (not $isDeployed) (eq .Type "secret")}}
<input type="hidden" name="{{.EnvVar}}" value="{{$val}}">
{{end}}
{{end}}
</div>
{{end}}
</div>
{{end}}
{{if .UserFields}}
<div class="form-section">
<h4>Beállítások</h4>
{{range .UserFields}}
<div class="form-group">
<label for="field-{{.EnvVar}}">
{{.Label}}
{{if or .Required (eq .Type "password")}}<span class="required">*</span>{{end}}
{{if .LockedAfterDeploy}}<span class="locked-hint">telepítés után nem módosítható</span>{{end}}
</label>
{{if eq .Type "subdomain"}}
<div class="subdomain-input-group">
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control subdomain-input"
{{/* R-351: a reinstall offers the address the customer's own backup recorded,
ahead of the catalog default. Editable — a domain can legitimately change. */}}
value="{{if and $.AlreadyDeployed $.DeployedFieldValues}}{{index $.DeployedFieldValues .EnvVar}}{{else if index $.RestoreFieldValues .EnvVar}}{{index $.RestoreFieldValues .EnvVar}}{{else}}{{.Default}}{{end}}"
placeholder="aldomain"
pattern="[a-z0-9]([a-z0-9-]*[a-z0-9])?"
required
{{if $.AlreadyDeployed}}disabled{{end}}
oninput="this.value=this.value.toLowerCase().replace(/[^a-z0-9-]/g,'')">
<span class="subdomain-suffix">.{{$.Domain}}</span>
</div>
{{if not $.AlreadyDeployed}}
<span class="form-hint">Az aldomain telepítés után nem módosítható. Megváltoztatáshoz az alkalmazás eltávolítása és újratelepítése szükséges (minden adat törlődik).</span>
{{end}}
{{else if eq .Type "select"}}
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
{{if $.AlreadyDeployed}}disabled{{end}}>
{{range .Options}}
<option value="{{.Value}}">{{.Label}}</option>
{{end}}
</select>
{{else if eq .Type "password"}}
<div class="input-with-button">
<input type="password" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{if and $.AlreadyDeployed $.DeployedFieldValues}}{{index $.DeployedFieldValues .EnvVar}}{{else}}{{.Default}}{{end}}"
placeholder="{{.Placeholder}}"
data-field-type="password"
required
{{if $.AlreadyDeployed}}disabled{{end}}>
<button type="button" class="btn btn-sm btn-outline pw-toggle-btn"
onclick="togglePasswordField('field-{{.EnvVar}}', 'field-confirm-{{.EnvVar}}', this)"
title="Megjelenítés">&#128065;</button>
{{if not $.AlreadyDeployed}}
<button type="button" class="btn btn-sm btn-outline"
onclick="generatePassword('field-{{.EnvVar}}', 'field-confirm-{{.EnvVar}}')">Generálás</button>
{{end}}
</div>
{{if $.AlreadyDeployed}}
<span class="form-hint">Telepítéskor beállított kezdeti jelszó — ha az alkalmazásban megváltoztattad, az itt nem frissül.</span>
{{else}}
<div class="input-with-button" style="margin-top:.25rem">
<input type="password" id="field-confirm-{{.EnvVar}}"
class="form-control" placeholder="Jelszó megerősítése"
data-confirm-for="field-{{.EnvVar}}">
</div>
{{end}}
{{else if eq .Type "boolean"}}
<label class="toggle">
<input type="checkbox" id="field-{{.EnvVar}}" name="{{.EnvVar}}" value="true"
{{if $.AlreadyDeployed}}disabled{{end}}>
<span class="toggle-label">Igen</span>
</label>
{{else if eq .Type "path"}}
{{if $.StoragePaths}}
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
{{if $.AlreadyDeployed}}disabled{{end}}
onchange="checkStorageSpace(this)">
{{range $.StoragePaths}}
<option value="{{.Path}}" data-free-percent="{{printf "%.0f" .FreePercent}}"
{{if .NotAllowed}}disabled{{end}}
{{/* R-351: on a reinstall the drive the BACKUP recorded wins over the
configured default — it is where this app's data actually lived, and
restoring into a different drive is the mismatch the restore then has
to stop and name. The customer can still pick another. */}}
{{if $.AlreadyDeployed}}{{if eq .Path $.CurrentHDDPath}}selected{{end}}{{else if $.RestorePrefillHDDPath}}{{if and (eq .Path $.RestorePrefillHDDPath) (not .NotAllowed)}}selected{{end}}{{else if and .IsDefault (not .NotAllowed)}}selected{{end}}>
{{.Label}} — {{.FreeHuman}} szabad{{if .NotAllowed}} ({{.NotAllowedNote}}){{else if .IsDefault}} (alapértelmezett){{end}}
</option>
{{end}}
{{if $.CurrentHDDPathMissing}}
<option value="{{$.CurrentHDDPath}}" selected disabled>{{$.CurrentHDDPath}} (nem elérhető)</option>
{{end}}
</select>
<div id="storage-space-warn" class="form-hint" style="color:var(--warn);display:none">
<svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> A kiválasztott tárhely majdnem megtelt.
</div>
<div class="form-hint" style="margin-top:.4rem;opacity:.8">
<svg class="ico ico-sm"><use href="#i-info"/></svg> A kiválasztott meghajtón az alkalmazás <strong>fájljai</strong> (média, dokumentumok) tárolódnak.
Az <strong>adatbázis a gyors belső SSD-n</strong> fut — és az alkalmazással együtt készül róla biztonsági mentés.
</div>
{{else}}
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}"
placeholder="{{.Placeholder}}"
{{if .Required}}required{{end}}
{{if $.AlreadyDeployed}}disabled{{end}}>
<span class="form-hint" style="color:var(--warn)">Nincs regisztrált adattároló — adja meg kézzel az útvonalat</span>
{{end}}
{{else}}
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}"
placeholder="{{.Placeholder}}"
{{if .Required}}required{{end}}
{{if $.AlreadyDeployed}}disabled{{end}}>
{{end}}
{{if and .Description (ne .Type "subdomain")}}
<span class="form-hint">{{.Description}}</span>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{if not .AlreadyDeployed}}
<div class="deploy-actions">
<button type="submit" class="btn btn-primary btn-lg"{{if .DockerBelowReserve}} disabled title="Nincs elég szabad tárhely"{{else if and .MemoryInfo (index .MemoryInfo "Blocked")}} disabled title="Nincs elég memória"{{end}}>Telepítés indítása</button>
<a href="/stacks" class="btn btn-outline">Mégsem</a>
</div>
{{end}}
</form>
<div id="deploy-progress" class="deploy-progress" style="display:none">
<h3>Telepítés 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">Konfiguráció mentése...</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">Konténer(ek) indítása...</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">Alkalmazás inicializálása...</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: {{json .Meta.AppInfo.DefaultCreds}},
docsURL: {{json .Meta.AppInfo.DocsURL}},
domain: {{json .Domain}},
displayName: {{json .Meta.DisplayName}},
deployFields: {{json .Meta.DeployFields}}
};
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">Alkalmazás megnyitása ↗</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>Első lépések</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">Megjelenítés</button>' +
'<button type="button" class="btn btn-sm btn-outline" onclick="copyPostDeployCred(this,\'' + val.replace(/'/g, "\\'") + '\')" style="padding:.15rem .4rem;font-size:.75rem">Másolás</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>Bejelentkezés</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>Bejelentkezés</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">Dokumentáció ↗</a></div>';
}
// Action buttons
html += '<div style="display:flex;gap:.75rem;margin-top:1.5rem;flex-wrap:wrap">' +
'<a href="/stacks/' + stackName + '/deploy" class="btn btn-outline">Beállítások és jelszavak megtekintése</a>' +
'<a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</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.
function revealAutoField(stackName, envVar, btn) {
var el = document.getElementById('auto-field-' + envVar);
if (!el) return;
if (btn.dataset.shown === '1') {
el.value = '';
el.type = 'password';
btn.dataset.shown = '';
btn.textContent = 'Megjelenítés';
return;
}
btn.disabled = true;
fetch('/stacks/' + encodeURIComponent(stackName) + '/auto-field/reveal', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/x-www-form-urlencoded'}, csrfHeaders()),
credentials: 'same-origin',
body: 'env_var=' + encodeURIComponent(envVar)
}).then(function (r) { return r.json(); }).then(function (j) {
btn.disabled = false;
if (!j.ok) { showAlert(j.error || 'A lekérés nem sikerült.'); return; }
el.value = j.data.value;
el.type = 'text';
btn.dataset.shown = '1';
btn.textContent = 'Elrejtés';
}).catch(function () { btn.disabled = false; showAlert('A lekérés nem sikerült.'); });
}
function toggleAutoField(fieldId, btn) {
var el = document.getElementById(fieldId);
if (!el) return;
el.type = el.type === 'password' ? 'text' : 'password';
btn.textContent = el.type === 'password' ? 'Megjelenítés' : 'Elrejtés';
}
function generatePassword(fieldId, confirmFieldId) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let pass = '';
const arr = new Uint8Array(16);
crypto.getRandomValues(arr);
for (let i = 0; i < 16; i++) {
pass += chars[arr[i] % chars.length];
}
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' ? 'Megjelenítés' : 'Elrejtés';
}
function togglePostDeployCred(btn, credId, val) {
var el = document.getElementById(credId);
if (!el) return;
if (el.textContent === val) {
el.textContent = '••••••••••••';
btn.textContent = 'Megjelenítés';
} else {
el.textContent = val;
btn.textContent = 'Elrejtés';
}
}
function copyPostDeployCred(btn, val) {
if (navigator.clipboard) {
navigator.clipboard.writeText(val).then(function() {
var orig = btn.textContent;
btn.textContent = 'Másolva!';
setTimeout(function() { btn.textContent = orig; }, 1500);
});
}
}
function deleteStaleData(stackName, stalePath, btn) {
// Two-step inline confirm (the irreversible delete keeps its double acknowledgement).
felhomConfirm(btn, 'Biztosan törölni szeretnéd a korábbi adatokat? Tárhely: ' + stalePath + ' — FIGYELEM: a művelet visszavonhatatlan! Előtte győződj meg róla, hogy az alkalmazás az új tárolóról megfelelően működik.', function () {
felhomConfirm(btn, 'UTOLSÓ FIGYELMEZTETÉS! A törlés visszavonhatatlan. Biztosan folytatod?', function () {
deleteStaleDataRun(stackName, stalePath, btn);
});
});
}
function deleteStaleDataRun(stackName, stalePath, btn) {
btn.disabled = true;
btn.textContent = 'Törlés folyamatban...';
fetch('/api/storage/stale-cleanup', {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
body: JSON.stringify({stack_name: stackName, stale_path: stalePath})
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.ok) {
showAlert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.disabled = false;
btn.textContent = 'Korábbi adatok törlése';
return;
}
var msg = 'Korábbi adatok törölve!\n\nFelszabadított hely: ' + (data.freed_human || '?');
if (data.errors && data.errors.length > 0) {
msg += '\n\nNéhány hiba történt:\n' + data.errors.join('\n');
}
showAlert(msg);
// Remove the stale data card from DOM
var item = btn.closest('.stale-data-item');
if (item) item.remove();
// If no more stale items, remove the whole section
var container = document.querySelector('.deploy-stale-data');
if (container && container.querySelectorAll('.stale-data-item').length === 0) {
container.remove();
}
})
.catch(function(e) {
showAlert('Hálózati hiba: ' + e.message);
btn.disabled = false;
btn.textContent = 'Korábbi adatok törlése';
});
}
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('Kötelező mező: ' + label + '\nHasználja a Generálás gombot vagy írjon be egy jelszót.');
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('A két jelszó 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('Az aldomain csak kisbetűket, számokat és kötőjelet tartalmazhat, és nem kezdődhet/végződhet kötőjellel.');
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('Kötelező mező: ' + label);
rf.focus();
return;
}
}
const btn = e.target.querySelector('[type=submit]');
btn.disabled = true;
btn.textContent = 'Telepítés 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})
});
var data = await resp.json();
if (!data.ok) {
showAlert('Hiba: ' + data.error);
btn.textContent = 'Telepítés indítása';
btn.disabled = false;
return;
}
// Deploy API returned success — switch to progress view
formEl.style.display = 'none';
progressEl.style.display = 'block';
setStep(stepConfig, 'done', 'Konfiguráció mentve');
setStep(stepContainers, 'active', 'Konténer(ek) indítása...');
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 = 120000;
var pollTimer = setInterval(async function() {
var elapsed = Math.round((Date.now() - startTime) / 1000);
elapsedEl.textContent = elapsed + ' másodperce...';
if (Date.now() - startTime > pollTimeout) {
clearInterval(pollTimer);
setStep(stepHealth, 'warn', 'Időtúllépés — az alkalmazás még indulhat');
resultEl.innerHTML = '<div class="alert alert-warning" style="margin-top:1rem">' +
'A telepítés időtúllépésbe futott. Az alkalmazás még indulhat.' +
'</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', 'Telepítés sikertelen');
setStep(stepHealth, 'error');
progressEl.querySelector('h3').textContent = 'Telepítés sikertelen';
resultEl.innerHTML = '<div class="alert alert-error" style="margin-top:1rem">' +
'A telepítés nem sikerült: ' + deployError +
'</div><a href="/stacks/' + stackName + '/logs" class="btn btn-outline" style="margin-top:.75rem">Naplók megtekintése</a>' +
' <a href="/stacks" class="btn btn-primary" style="margin-top:.75rem">Alkalmazások</a>';
resultEl.style.display = 'block';
} else if (state === 'deploying') {
// Compose up in progress (pulling images, creating containers)
setStep(stepContainers, 'active', 'Képek letöltése, konténerek indítása...');
} else if (state === 'running') {
clearInterval(pollTimer);
setStep(stepContainers, 'done', 'Konténerek elindultak');
setStep(stepHealth, 'done', 'Alkalmazás kész!');
progressEl.querySelector('h3').textContent = 'Telepítés sikeres!';
resultEl.innerHTML = '<div class="alert alert-info" style="margin-top:1rem">' +
'Az alkalmazás sikeresen telepítve és fut!' +
'</div>' + buildPostDeployCard(stackName);
resultEl.style.display = 'block';
} else if (state === 'starting') {
setStep(stepContainers, 'done', 'Konténerek elindultak');
setStep(stepHealth, 'active', 'Alkalmazás inicializálása...');
} else if (state === 'unhealthy') {
clearInterval(pollTimer);
setStep(stepContainers, 'done', 'Konténerek elindultak');
setStep(stepHealth, 'warn', 'Állapotjelző: még inicializál');
progressEl.querySelector('h3').textContent = 'Telepítés sikeres!';
resultEl.innerHTML = '<div class="alert alert-warning" style="margin-top:1rem">' +
'Az alkalmazás elindult, de még inicializálódik. ' +
'Ez normális az első percekben — az alkalmazás már elérhető lehet.' +
'</div>' + buildPostDeployCard(stackName);
resultEl.style.display = 'block';
} else if (state === 'exited' || state === 'stopped') {
clearInterval(pollTimer);
setStep(stepContainers, 'error', 'A konténer leállt');
setStep(stepHealth, 'error');
progressEl.querySelector('h3').textContent = 'Telepítés sikertelen';
resultEl.innerHTML = '<div class="alert alert-error" style="margin-top:1rem">' +
'A konténer leállt. Ellenőrizze a naplókat.' +
'</div><a href="/stacks/' + stackName + '/logs" class="btn btn-outline" style="margin-top:.75rem">Naplók megtekintése</a>' +
' <a href="/stacks" class="btn btn-primary" style="margin-top:.75rem">Alkalmazások</a>';
resultEl.style.display = 'block';
}
} catch(pollErr) {}
}, 3000);
} catch (err) {
showAlert('Hálózati hiba: ' + err.message);
btn.textContent = 'Telepítés indítása';
btn.disabled = false;
}
});
</script>
{{template "layout_end" .}}
{{end}}