7f3e58ff0a
gates / gates (push) Failing after 49s
v0.315.0 worked and looked, in the operator's words, messy: boxes of different widths stacked down
the page, with the same card shape around things you act on and things you only read. The mockup
approved on 2026-10-15 replaces it, and each of its three rules has a reason:
ONE COLUMN OF ONE WIDTH (820px). Differing widths read as a page assembled by accident.
SECTIONS ARE HAIRLINES, NOT CARDS. A card says "this is a thing you act on"; most of an app page
is a thing you READ, so a card there is a promise the content cannot keep.
A BOX MEANS SOMETHING. Exactly two remain: blue when the app is waiting for the household,
amber when something is risky. The exception-colour principle applied to shape as well.
What moved:
- the hero loses its box: a 64px logo tile, the name at 26px, the tagline, then hairline chips
(state, lifecycle, update) and meta text (the address, the last automatic update);
- the tabs are links with a 2px blue underline under the open one — no pills, no fills;
- sections are `padding: 28px 0` with a bottom hairline; rows are a 200px label column so every
value on the page starts at the same x, and stack under 720px;
- the after-start callout is now the FIRST thing on Beallitasok. A household opening a Plex that
wants its claim code should meet the code box, not a Restart button;
- "2. mentes beallitasa — <app>" and "<app> — Exportalas" are gone: they were the headings of
pages that no longer exist, on a tab that already says which app this is;
- the settings tab gains "Cim es kulcsok" with one address row, and the disabled subdomain input
that said the same thing is skipped for an installed app (the install wizard still asks);
- the "this app is already installed, the settings are read-only" BANNER becomes the hint under
the fields it is about;
- the install wizard gets the same back link, hero and column, so pressing Telepites does not
land the household on a page that looks like a different product.
THE AUTO-UPDATE SUCCESS BANNER IS GONE. It was a full-width blue box above the tabs, level with
"the update failed" and "your files are missing". A success that asked nothing of the household is
a date, and it is now meta text in the hero. Every banner that DOES need a decision — a failed
update, a hold, lost files, a missing drive, an interrupted install — is untouched, above the tabs,
exactly where R-571 puts it.
Tests: TestAppPage_OneColumnLayout (one column, one aria-current, no card-shaped section, no
success banner), TestAppPage_AutoUpdateSuccessIsMetaNotBanner, TestAppPage_CalloutsOnlyForAskAndWarn
(one blue callout on a waiting app and it comes first; one amber on a same-disk backup; none on the
good case). Four red-proofs recorded in REPORT.
app_row_dedup_gate convicted `.app-row`, correctly: that class already means "a row in the app
list". The app page's label/value row is `.app-field-row`. Restoring the list's own rule after an
over-broad rename was part of the fix.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
536 lines
28 KiB
HTML
536 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. */}}
|
|
|
|
<a href="/apps/{{.Meta.Slug}}" class="app-back" data-install-back="1">{{T "deploy.vissza"}}</a>
|
|
|
|
<div class="app-page 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}}
|
|
{{/* v0.316.0: the same hero as the app page, so pressing „Telepites" does not land the
|
|
household on a page that looks like a different product. */}}
|
|
<div class="app-hero">
|
|
<img class="app-hero-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 class="app-hero-text">
|
|
<h2 class="app-hero-name">{{.Meta.DisplayName}} — {{T "deploy.telepites"}}</h2>
|
|
{{if .Meta.Description}}<p class="app-hero-tagline">{{.Meta.Description}}</p>{{end}}
|
|
<div class="app-chips">
|
|
{{if .Meta.Resources.MemRequest}}<span class="app-chip">~{{.Meta.Resources.MemRequest}} RAM</span>{{end}}
|
|
{{if .Meta.Resources.NeedsHDD}}<span class="app-chip">{{T "common.hdd_szukseges"}}</span>{{end}}
|
|
{{if .Meta.Resources.HungarianUI}}<span class="app-chip">{{T "common.magyar_felulet"}}</span>{{end}}
|
|
<a href="/apps/{{.Meta.Slug}}" class="app-meta" style="text-decoration:none">{{T "deploy.reszletes_leiras_kepernyokepek"}}</a>
|
|
</div>
|
|
</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}}
|