v0.315.0: one app page with four tabs; the guide the catalog can carry; no Raspberry Pi badge
gates / gates (push) Failing after 47s

An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.

  settings  Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
            household + the install-time and optional settings. The default for an installed app,
            RENDERED at the bare URL and never redirected to.
  data      the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
  guide     tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
            link — and the WHOLE page for an app that is not installed
  logs      the log, and the only tab whose poller runs

The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.

The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.

A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.

New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.

The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).

template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
This commit is contained in:
2026-10-15 18:37:50 +02:00
parent 85f65c70d8
commit e66d4669d6
120 changed files with 16239 additions and 9866 deletions
@@ -1,13 +1,192 @@
{{define "app_export"}}
{{define "app_data"}}
{{template "layout_start" .}}
{{template "app_shell_start" .}}
<div class="page-header">
<div style="display:flex;align-items:center;gap:1rem">
<a href="/stacks" class="btn btn-sm btn-outline">{{T "app_export.alkalmazasok"}}</a>
<h2>{{T "app_export.exportalas"}}</h2>
{{/* „ADATOK ES MENTES" — everything about this app's FILES, in the order the household asks it:
where are they -> „Hova tegyem a fajlokat?" and the move-to-another-drive offer
are they safe -> the nightly copy, and the 2nd-copy setting that used to be reachable only
from the backup page's „Beallitas" button
how do I get them out -> the export, which was a page of its own
Both forms keep the URLs they always POSTed to (`/stacks/{name}/backup` and the export API), so
nothing about saving changed — only where the household finds the controls. */}}
<div class="app-data-tab">
{{if .Flash}}<div class="monitoring-banner monitoring-banner-green">{{.Flash}}</div>{{end}}
{{if .FlashError}}<div class="monitoring-banner monitoring-banner-red">{{.FlashError}}</div>{{end}}
{{if .DataPathCards}}
<div class="app-info-card" style="margin-top:1rem">
<h3>{{T "app_info.hova_tegyem_a_fajlokat"}}</h3>
<p class="form-hint">{{T "app_info.ezeket_a_mappakat_a_fajlkezeloben"}}</p>
<div class="datapath-list">
{{range .DataPathCards}}
<div class="datapath-row">
<div class="datapath-head">
<a href="{{.Link}}" target="_blank" rel="noopener" class="datapath-link">{{.Label}} ↗</a>
{{if .FreeSpace}}<span class="datapath-space">{{.FreeSpace}}</span>{{end}}
</div>
{{if .Consequence}}<p class="datapath-note">{{.Consequence}}</p>{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
{{if and .Stack.Deployed .MigrateTargets}}
<div class="app-info-card" style="margin-top:1rem">
<h3>{{T "app_info.athelyezes_masik_tarhelyre"}}</h3>
<p class="form-hint">{{T "app_info.ennek_az_alkalmazasnak_az_adatait"}}</p>
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-top:.5rem">
<select id="app-migrate-target" class="btn btn-sm btn-outline">
<option value="">{{T "app_info.valassz_celtarhelyet"}}</option>
{{range .MigrateTargets}}<option value="{{.Path}}">{{.Label}} ({{.Path}})</option>{{end}}
</select>
<button class="btn btn-sm btn-outline" onclick="appMigrate(this,'{{.Stack.Name}}','{{.Meta.DisplayName}}')">{{T "app_info.athelyezes"}}</button>
</div>
<div id="app-migrate-progress" style="display:none;margin-top:.75rem;padding:.75rem;border:1px solid var(--blue);border-radius:6px;background:rgba(0,136,204,0.06)"></div>
</div>
<script>
function appMigFmtGB(b){ return fhNum(Number(b||0)/1e9, 1)+' GB'; }
function appMigRender(job){
var names={stop:'{{T "app_info.leallitas"}}',copy:'{{T "app_info.adatok_masolasa"}}',verify:'{{T "app_info.ellenorzes"}}',flip:'{{T "app_info.ujratelepites"}}',redeploy:'{{T "app_info.ujratelepites"}}',cleanup:'{{T "app_info.regi_adatok_torlese"}}'};
var s=names[job.phase]||job.phase;
if(job.phase==='copy'&&job.bytes_total>0){ s+=' ('+Math.floor(100*job.bytes_done/job.bytes_total)+'% — '+appMigFmtGB(job.bytes_done)+'/'+appMigFmtGB(job.bytes_total)+')'; }
return s+'…';
}
function appMigWatch(){
var panel=document.getElementById('app-migrate-progress');
if(panel) panel.style.display='block';
function tick(){
fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){
var job=d.data&&d.data.job;
if(!job){ if(panel) panel.textContent='{{T "app_info.nincs_folyamatban_athelyezes"}}'; return; }
if(panel) panel.innerHTML=appMigRender(job);
if(job.phase==='done'){ if(panel) panel.innerHTML+='<br><strong>{{T "app_info.kesz"}}</strong>'; setTimeout(function(){location.reload();},1500); return; }
if(job.phase==='aborted'){ if(panel) panel.innerHTML+='<br><strong style="color:var(--danger,#c0392b)">{{T "app_info.megszakadt"}} '+(job.error||'')+'</strong><br>{{T "app_info.a_regi_adatok_erintetlenek"}}'; return; }
setTimeout(tick,1500);
}).catch(function(){ setTimeout(tick,2000); });
}
tick();
}
function appMigrate(btn,app,label){
var sel=document.getElementById('app-migrate-target');
var target=sel?sel.value:'';
if(!target){ showAlert('{{T "app_info.valassz_celtarhelyet_2"}}'); return; }
felhomConfirm(btn,'{{T "app_info.athelyezed_a_z"}} '+label+' {{T "app_info.adatait_ide"}} '+target+'{{T "app_info.az_alkalmazas_rovid_idore_leall"}}',function(){
fetch('/api/storage/migrate-app',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({app:app,target:target})})
.then(function(r){return r.json();}).then(function(d){ if(d.ok){ appMigWatch(); } else { showAlert('{{T "common.hiba_kettospont"}} '+(d.error||'{{T "app_info.ismeretlen"}}')); } })
.catch(function(e){ showAlert('{{T "common.hiba_kettospont"}} '+e); });
});
}
(function(){ fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){ if(d.data&&d.data.job){ appMigWatch(); } }).catch(function(){}); })();
</script>
{{end}}
{{/* The nightly copy, stated as a fact. It was a card on the settings page; the files it is about
live here. */}}
<div class="deploy-cross-drive">
<h4>{{T "deploy.biztonsagi_mentes"}}</h4>
<div class="cross-drive-nightly">
<span class="form-hint" style="display:block;margin-top:.25rem">
{{T "deploy.az_alkalmazas_adatbazisa_es_docker"}}
</span>
</div>
</div>
{{if .Tier2}}
<h3 style="margin-top:1.5rem">{{T "tier2_config.2_mentes_beallitasa"}}</h3>
<div class="monitor-card">
{{with .Tier2}}
<p style="color:var(--text-3);font-size:.9rem;margin-top:0">
{{T "tier2_config.a_2_mentes_egy_masik"}}
</p>
{{if not .IsHDDApp}}
{{if eq $.SystemBackup "protected"}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="protected">
{{T "tier2_config.rendszerlemez.vedett"}}
</div>
{{else if eq $.SystemBackup "same_disk"}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="same_disk">
{{T "tier2_config.rendszerlemez.ugyanaz"}}
</div>
{{else if eq $.SystemBackup "absent"}}
<div class="monitoring-banner monitoring-banner-red" style="margin-top:1rem" data-system-backup="absent">
{{T "tier2_config.rendszerlemez.hianyzik"}}
</div>
{{else}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="unknown">
{{T "tier2_config.rendszerlemez.ismeretlen"}}
</div>
{{end}}
{{else}}
<h3 style="margin-top:1.5rem">{{T "tier2_config.jelenlegi_allapot"}}</h3>
<div class="sysinfo-grid">
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.2_mentes"}}</span>
<span class="sysinfo-value">{{if .Disabled}}{{T "tier2_config.kikapcsolva"}}{{else}}{{T "tier2_config.bekapcsolva"}}{{end}}</span>
</div>
{{if .NoTarget}}
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.cel"}}</span>
<span class="sysinfo-value text-error">{{T "tier2_config.nincs_elerheto_off_drive_cel"}}</span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.megjegyzes"}}</span>
<span class="sysinfo-value" style="color:var(--text-3)">{{.NoTargetReason}}</span>
</div>
{{else}}
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.cel_meghajto"}}</span>
<span class="sysinfo-value">{{.EffectiveLabel}}{{if .EffectiveIsSSD}} {{T "tier2_config.csak_db_konfiguracio"}}{{end}}</span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.kivalasztas_modja"}}</span>
<span class="sysinfo-value" style="color:var(--text-3)">{{if .Preferred}}{{T "tier2_config.kezi_valasztas"}}{{else}}{{T "tier2_config.automatikus_kisbetu"}}{{end}} — {{.EffectiveDesc}}</span>
</div>
{{end}}
</div>
{{if .EffectiveIsSSD}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem">
{{T "tier2_config.jelenleg_csak_a_belso_ssd"}}
</div>
{{end}}
<form method="POST" action="/stacks/{{$.StackName}}/backup" style="margin-top:1.5rem">
{{$.CSRFField}}
<div class="form-group">
<label style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" name="enabled" value="on" {{if not .Disabled}}checked{{end}}>
{{T "tier2_config.2_mentes_bekapcsolva"}}
</label>
</div>
<div class="form-group">
<label for="target">{{T "tier2_config.cel_meghajto"}}</label>
<select id="target" name="target" class="form-control">
<option value="">{{T "tier2_config.automatikus"}}{{if not .NoTarget}} {{T "tier2_config.jelenleg"}}{{end}}</option>
{{range .Alternatives}}
<option value="{{.Path}}" {{if eq .Path $.Tier2.Preferred}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
{{if not .Alternatives}}
<div style="font-size:.8rem;color:var(--text-3);margin-top:.4rem">
{{T "tier2_config.nincs_masik_adatmeghajto_automatikus_cel"}}
</div>
{{end}}
</div>
<button type="submit" class="btn btn-primary">{{T "tier2_config.mentes"}}</button>
</form>
{{end}}
{{end}}
</div>
{{end}}
{{if .ExportOffered}}
<h3 style="margin-top:2rem">{{T "app_export.exportalas"}}</h3>
<div class="card" style="max-width:700px">
<h3>{{T "app_export.mentes_helye"}}</h3>
<select id="destDrive" onchange="loadEstimate()" style="width:100%;padding:.5rem;margin-bottom:1rem">
@@ -312,5 +491,9 @@ function showDone(data) {
}
</script>
{{end}}
</div>
{{template "app_press_script" .}}
{{template "layout_end" .}}
{{end}}
@@ -0,0 +1,181 @@
{{/* THE APP'S CONFIGURATION FIELDS — shared by the install wizard and the settings tab.
ONE copy of this markup, rendered by two surfaces, because they are the same fields: the
install page submits them, the settings tab shows what was submitted. `.AlreadyDeployed` is
the single flag every branch below reads, exactly as it did when both surfaces were one page —
that is what keeps the install form and the settings view from drifting apart.
The secret rules are unchanged and load-bearing:
R-254 — an INSTALLED app's `type: secret` value is not in the page; the eye fetches it.
R-709 — nor is a `type: password` one.
The PRE-DEPLOY hidden input is deliberate: the form must carry what it submits, so the value
saved is the one the household was shown (controller/README §318). */}}
{{define "app_deploy_fields"}}
{{if .AutoFields}}
<div class="form-section">
<h4>{{T "deploy.automatikusan_generalt_ertekek"}}</h4>
{{if .AlreadyDeployed}}
<p class="form-section-desc">{{T "deploy.ezek_az_ertekek_automatikusan_jottek"}}</p>
{{else}}
<p class="form-section-desc">{{T "deploy.ezek_az_ertekek_a_telepitessel"}}</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> {{T "deploy.automatikusan_generalva"}}</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)">{{T "deploy.megjelenites"}}</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)">{{T "deploy.megjelenites"}}</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>{{T "deploy.beallitasok"}}</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">{{T "deploy.telepites_utan_nem_modosithato"}}</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="{{T "deploy.aldomain_placeholder"}}"
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">{{T "deploy.az_aldomain_telepites_utan_nem"}}</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">
{{- if $.AlreadyDeployed}}
{{- /* R-709 (v0.280.0): an installed app's password is never in this page; the eye fetches it. */}}
<input type="password" id="field-{{.EnvVar}}" class="form-control" value="" placeholder="••••••••••••" disabled>
{{- if not (and $.RestoredLogins (index $.RestoredLogins .EnvVar))}}
<button type="button" class="btn btn-sm btn-outline pw-toggle-btn"
onclick="revealPasswordField('{{$.Stack.Name}}', '{{.EnvVar}}', this)"
title="{{T "deploy.megjelenites"}}">&#128065;</button>
{{- end}}
{{- else}}
<input type="password" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}"
placeholder="{{.Placeholder}}"
data-field-type="password"
required>
<button type="button" class="btn btn-sm btn-outline pw-toggle-btn"
onclick="togglePasswordField('field-{{.EnvVar}}', 'field-confirm-{{.EnvVar}}', this)"
title="{{T "deploy.megjelenites"}}">&#128065;</button>
<button type="button" class="btn btn-sm btn-outline"
onclick="generatePassword('field-{{.EnvVar}}', 'field-confirm-{{.EnvVar}}', '{{.Generate}}')">{{T "deploy.generalas"}}</button>
{{- end}}
</div>
{{if $.AlreadyDeployed}}
<span class="form-hint">{{if and $.RestoredLogins (index $.RestoredLogins .EnvVar)}}{{T "deploy.login_from_backup"}}{{else}}{{T "deploy.telepiteskor_beallitott_kezdeti_jelszo_h"}}{{end}}</span>
{{else}}
<div class="input-with-button" style="margin-top:.25rem">
<input type="password" id="field-confirm-{{.EnvVar}}"
class="form-control" placeholder="{{T "deploy.jelszo_megerositese"}}"
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">{{T "deploy.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}}>
{{T "deploy.szabad_2"}}{{if .NotAllowed}} ({{.NotAllowedNote}}){{else if .IsDefault}} {{T "deploy.alapertelmezett"}}{{end}}
</option>
{{end}}
{{if $.CurrentHDDPathMissing}}
<option value="{{$.CurrentHDDPath}}" selected disabled>{{T "deploy.nem_elerheto"}}</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> {{T "deploy.a_kivalasztott_tarhely_majdnem_megtelt"}}
</div>
<div class="form-hint" style="margin-top:.4rem;opacity:.8">
<svg class="ico ico-sm"><use href="#i-info"/></svg> {{T "deploy.a_kivalasztott_meghajton_az_alkalmazas"}}
</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)">{{T "deploy.nincs_regisztralt_adattarolo_adja_meg"}}</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}}
{{end}}
@@ -0,0 +1,109 @@
{{define "app_guide"}}
{{template "layout_start" .}}
{{template "app_shell_start" .}}
{{/* „UTMUTATO" — the app's own copy, and the WHOLE page for an app that is not installed yet.
It replaces the old details page: description and screenshots, what it is for, the first steps,
the guide the catalog carries, the questions people ask, and the official documentation.
THE GUIDE RENDERS ONLY WHEN ITS AUTHOR NAMED THE VERSION THEY CHECKED IT AGAINST
(AppGuide.Rendered). A guide walking the household through a screen two releases old is worse
than no guide, so an unchecked one is not shown and the page says a guide is still to come.
Everything here is PLAIN TEXT through html/template. No markdown, no raw HTML: a step that
contains a tag is shown as the characters the author typed. */}}
<!-- Screenshots (graceful — hidden if assets don't exist) -->
<div class="app-screenshots" id="screenshots">
<img src="{{screenshotURL .Meta.Slug 1}}" alt="" class="app-screenshot"
onerror="this.style.display='none'">
<img src="{{screenshotURL .Meta.Slug 2}}" alt="" class="app-screenshot"
onerror="this.style.display='none'">
<img src="{{screenshotURL .Meta.Slug 3}}" alt="" class="app-screenshot"
onerror="this.style.display='none'">
</div>
{{if not .Deployed}}
{{/* The install button lives on this tab, because this tab IS the page for a not-installed app.
The server refuses a non-installable template regardless; hiding the button keeps the page
from offering an action that would be rejected. */}}
<div class="app-info-card" style="margin-top:1rem">
{{if canInstall .Meta}}
<a href="/apps/{{.Meta.Slug}}/install" class="btn btn-primary" data-install-cta="1"
onclick="return checkBeforeDeploy(event, '{{.Stack.Name}}')">{{T "app_info.telepites"}}</a>
{{else}}
<p class="form-hint" data-not-installable="1">{{T "app_page.not_installable"}}</p>
{{end}}
</div>
{{end}}
<div class="app-info-grid">
{{if .AppInfo.UseCases}}
<div class="app-info-card">
<h3>{{T "app_info.mire_hasznalhato"}}</h3>
<ul class="app-info-list">
{{range .AppInfo.UseCases}}<li>{{.}}</li>{{end}}
</ul>
</div>
{{end}}
{{if .AppInfo.FirstSteps}}
<div class="app-info-card">
<h3>{{T "app_info.elso_lepesek"}}</h3>
<ol class="app-info-list">
{{range .AppInfo.FirstSteps}}<li>{{.}}</li>{{end}}
</ol>
</div>
{{end}}
{{if .AppInfo.Prerequisites}}
<div class="app-info-card">
<h3>{{T "app_info.elofeltetelek"}}</h3>
<ul class="app-info-list">
{{range .AppInfo.Prerequisites}}<li>{{.}}</li>{{end}}
</ul>
</div>
{{end}}
</div>
{{/* THE GUIDE. */}}
{{if .AppInfo.Guide.Rendered}}
<div class="app-guide" data-guide="1" data-guide-checked="{{.AppInfo.Guide.CheckedVersion}}">
{{- range .AppInfo.Guide.Sections}}
<div class="app-info-card" style="margin-top:1rem" data-guide-section="1">
<h3>{{.Title}}</h3>
{{if .Intro}}<p>{{.Intro}}</p>{{end}}
{{if .Steps}}
<ol class="app-info-list">
{{- range .Steps}}<li>{{.}}</li>{{end}}
</ol>
{{end}}
</div>
{{- end}}
{{- if .AppInfo.Guide.FAQ}}
<div class="app-info-card" style="margin-top:1rem" data-guide-faq="1">
<h3>{{T "app_page.faq"}}</h3>
<dl class="app-faq">
{{- range .AppInfo.Guide.FAQ}}
<dt>{{.Q}}</dt>
<dd>{{.A}}</dd>
{{- end}}
</dl>
</div>
{{- end}}
</div>
{{else}}
<p class="form-hint" style="margin-top:1rem" data-guide-missing="1">{{T "app_page.guide_missing"}}</p>
{{end}}
<div class="app-info-grid">
{{if .AppInfo.DocsURL}}
<div class="app-info-card">
<h3>{{T "app_info.dokumentacio"}}</h3>
<p><a href="{{.AppInfo.DocsURL}}" target="_blank" class="app-info-link">{{T "app_info.hivatalos_dokumentacio"}}</a></p>
</div>
{{end}}</div>
{{template "app_press_script" .}}
{{template "layout_end" .}}
{{end}}
@@ -268,6 +268,15 @@ function pollStatus() {
document.getElementById('progressCard').style.display = 'none';
document.getElementById('doneCard').style.display = 'block';
if (data.stack_name) {
{{- /* v0.315.0: the browser knows the imported app's stack NAME and not its
slug, and the name->slug map lives on the server. This is the one link
that keeps the old URL on purpose: its 302 resolves the slug and lands on
the settings tab.
The leading `{{-` is deliberate: a comment action renders nothing, but the
indentation on its own line does. Without the trim this page's bytes
change and its parity fixture — a page this task does not redesign — would
have to be regenerated for a comment. */}}
document.getElementById('doneLink').href = '/stacks/' + data.stack_name + '/deploy';
document.getElementById('doneLink').textContent = '{{T "app_import.alkalmazas_megtekintese"}}';
}
@@ -1,405 +0,0 @@
{{define "app_info"}}
{{template "layout_start" .}}
<div class="page-header">
<div style="display:flex;align-items:center;gap:1rem">
<a href="/stacks" class="btn btn-sm btn-outline">{{T "app_info.alkalmazasok"}}</a>
<h2>{{.Meta.DisplayName}}</h2>
</div>
<div style="display:flex;align-items:center;gap:.5rem">
{{if .Stack.Deployed}}
<span class="stack-state-badge state-{{stateColor .Stack.State}}">{{stateLabel .Stack.State}}</span>
{{if .Stack.Orphaned}}<span class="badge badge-orphaned">{{T "common.elavult"}}</span>{{end}}
{{template "meta_badge" (lifecycleBadge .Meta)}}
{{template "meta_badge" (updateBadge .Stack)}}{{if and (gt .Stack.LadderStepsLeft 0) (not .Stack.HoldReason)}}<small class="text-muted" data-ladder-steps="{{.Stack.LadderStepsLeft}}">{{T "app_info.ladder_steps_left"}} {{.Stack.LadderStepsLeft}}</small>{{end}}
{{if .EffectiveSubdomain}}<a href="https://{{.EffectiveSubdomain}}.{{.Domain}}{{.Meta.OpenPath}}" target="_blank" class="btn btn-sm btn-outline">{{T "app_info.megnyitas"}}</a>{{end}}
<a href="/stacks/{{.Stack.Name}}/logs" class="btn btn-sm btn-outline">{{T "app_info.naplo"}}</a>
{{if .Stack.Orphaned}}
<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Stack.Name}}')">{{T "common.torles"}}</button>
{{else}}
<a href="/stacks/{{.Stack.Name}}/export" class="btn btn-sm btn-outline">{{T "app_info.exportalas"}}</a>
<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-outline">{{T "app_info.beallitasok"}}</a>
{{end}}
{{else}}
{{/* Server-side the deploy endpoint refuses a non-installable template; hide the button
so the page never offers an action that would be rejected. */}}
{{if canInstall .Meta}}<a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-primary" onclick="return checkBeforeDeploy(event, '{{.Stack.Name}}')">{{T "app_info.telepites"}}</a>{{end}}
{{end}}
</div>
</div>
{{- if .LostFiles}}
<div class="app-info-card alert-warning" style="margin-top:1rem" id="lost-files-card">
<h3>{{T "app_info.lost_files_title"}}</h3>
<p>{{T "app_info.lost_files_text"}}{{if .LostBootAt}} <strong data-lost-boot-at>{{.LostBootAt}}</strong>{{end}}</p>
<ul data-lost-files>
{{- range .LostFiles}}
<li><span class="mono">{{.Name}}</span>{{if .Taken}} — {{T "app_info.lost_files_taken"}} {{.Taken}}{{end}}</li>
{{- end}}
</ul>
<p>{{T "app_info.lost_files_hint"}}</p>
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/lost-files/dismiss')">{{T "app_info.lost_files_dismiss_btn"}}</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
{{- end}}
{{if .Stack.Updating}}
<div class="alert alert-info" style="margin-top:1rem" data-update-phase="{{.Stack.UpdatePhase}}">{{updatePhaseText .Stack}}</div>
{{else if and .Stack.HoldReason (eq .Stack.HoldKind "unhealthy_stop")}}
<div class="alert alert-warning" style="margin-top:1rem" data-unhealthy-stop="true">{{holdText .Stack}}</div>
{{else if .Stack.HoldReason}}
<div class="alert alert-error" style="margin-top:1rem" data-held="true">{{holdText .Stack}}{{if not .Stack.HoldNoWholeCopy}} <a href="/backups/apps" class="btn btn-sm btn-outline">{{T "app_info.mentesek"}}</a>{{end}}{{if .HoldLogKept}}<br><span data-hold-log="true">{{T "app_info.hold_log_kept"}}</span> <a href="/stacks/{{.Stack.Name}}/logs" class="btn btn-sm btn-outline">{{T "app_info.naplo"}}</a>{{end}}</div>
{{else if .Stack.UpdateError}}
<div class="alert alert-warning" style="margin-top:1rem" data-update-error="true">{{updateErrorText .Stack}}</div>
{{else if .UpdateUndoneLine}}
<div class="alert alert-info" style="margin-top:1rem" data-update-undone="true">{{.UpdateUndoneLine}}</div>
{{else if .AutoUpdateLine}}
<div class="alert alert-info" style="margin-top:1rem" data-auto-update="done">{{.AutoUpdateLine}}</div>
{{end}}{{if and .Stack.HealthProbe .Stack.HealthProbe.NotChecked}}{{/* R-772: the declared health check could not run — said, never shown as healthy. */}}
<p class="form-hint" style="margin-top:0.5rem" data-health-not-checked="true">{{T "health.no_probe_container"}}</p>{{end}}
{{if .MissingStorageLabel}}
<div class="alert alert-warning" style="margin-top:1rem">
<strong><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> {{T "app_info.hianyzo_tarhely"}} {{.MissingStorageLabel}}</strong><br>
{{T "app_info.ennek_az_alkalmazasnak_az_adattaroloja"}}
</div>
{{end}}
<!-- Hero section -->
<div class="app-info-hero">
<img class="app-info-logo" src="{{logoURL .Meta.Slug}}"
alt="{{.Meta.DisplayName}}" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Meta.Slug}}';}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-info-hero-text">
{{if .AppInfo.Tagline}}
<p class="app-info-tagline">{{.AppInfo.Tagline}}</p>
{{else}}
<p class="app-info-tagline">{{.Meta.Description}}</p>
{{end}}
<div class="stack-meta-badges">
<span class="meta-badge">~{{.Meta.Resources.MemRequest}} RAM</span>
<span class="meta-badge">{{.Meta.Category}}</span>
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge meta-badge-warn">{{T "common.hdd_szukseges"}}</span>{{end}}
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">{{T "common.pi_kompatibilis"}}</span>{{else}}<span class="meta-badge meta-badge-warn">{{T "app_info.csak_x86"}}</span>{{end}}
{{if .Meta.Resources.HungarianUI}}<span class="meta-badge meta-badge-ok">{{T "common.magyar_felulet"}}</span>{{end}}
</div>
{{if .Meta.IsAbandoned}}
<div class="alert alert-warning" style="margin-top:.75rem">
{{T "app_info.az_alkalmazas_fejlesztoje_felhagyott_a"}}
</div>
{{end}}
</div>
</div>
<!-- Screenshots (graceful — hidden if assets don't exist) -->
<div class="app-screenshots" id="screenshots">
<img src="{{screenshotURL .Meta.Slug 1}}" alt="" class="app-screenshot"
onerror="this.style.display='none'">
<img src="{{screenshotURL .Meta.Slug 2}}" alt="" class="app-screenshot"
onerror="this.style.display='none'">
<img src="{{screenshotURL .Meta.Slug 3}}" alt="" class="app-screenshot"
onerror="this.style.display='none'">
</div>
{{- if .InstallHoldClosed}}
<div class="app-info-card" style="margin-top:1rem" id="install-hold-card">
<h3>{{T "app_info.install_hold_title"}}</h3>
<p>{{T "app_info.install_hold_closed"}}</p>
</div>
{{- end}}
{{- if .SetupGateClosed}}
<div class="app-info-card" style="margin-top:1rem" id="setup-gate-card">
<h3>{{T "app_info.setup_gate_title"}}</h3>
<p>{{T "app_info.setup_gate_closed"}}</p>{{- if .SignupNative}}
<p class="form-hint" data-restarts-once="true">{{T "app_info.restarts_once"}}</p>{{- end}}
{{- if .PlexClaim}}
<div class="plex-claim" data-plex-claim="true">
<p>{{T "app_info.plex_claim_intro"}}</p>
<div class="input-with-button">
<input type="text" id="plex-claim-code" class="form-control" placeholder="claim-xxxxxxxxxxxxxxxxxxxx" autocomplete="off" spellcheck="false">
<button type="button" class="btn btn-sm btn-primary" id="plex-claim-btn" onclick="plexClaim(this)">{{T "app_info.plex_claim_btn"}}</button>
</div>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
{{- end}}
{{- if .SetupGateHasProbe}}
<p class="form-hint">{{T "app_info.setup_gate_probe"}}</p>
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/setup-gate/open')">{{T "app_info.setup_gate_done_btn"}}</button>
{{- else}}
<p class="form-hint">{{T "app_info.setup_gate_button_hint"}}</p>
<button type="button" class="btn btn-sm btn-primary" onclick="var b=this; felhomConfirm(b, '{{T "app_info.setup_gate_confirm"}}', function(){ appPress(b, '/apps/{{.Meta.Slug}}/setup-gate/open'); })">{{T "app_info.setup_gate_done_btn"}}</button>
{{- end}}
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
{{- end}}
{{- if .CloseSignupOffered}}
<div class="app-info-card" style="margin-top:1rem" id="close-signup-card">
<h3>{{T "app_info.signup_title"}}</h3>
<p>{{T "app_info.close_signup_text"}}</p>{{- if .SignupNative}}
<p class="form-hint" data-restarts-once="true">{{T "app_info.restarts_once"}}</p>{{- end}}
<button type="button" class="btn btn-sm btn-primary" onclick="appPress(this, '/apps/{{.Meta.Slug}}/close-signup')">{{T "app_info.close_signup_btn"}}</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
{{- end}}
{{- if or .SignupClosed .SignupOpenUntil}}
<div class="app-info-card" style="margin-top:1rem" id="signup-card">
<h3>{{T "app_info.signup_title"}}</h3>
{{- if .SignupOpenUntil}}
<p>{{T "app_info.signup_open_until"}} {{.SignupOpenUntil}}</p>
{{- else}}
<p>{{T "app_info.signup_closed"}}</p>
{{- end}}
{{- if .AppInfo.AddPeople}}
<p class="form-hint" id="add-people">{{T "app_info.signup_add_how"}} {{.AppInfo.AddPeople}}</p>
{{- end}}
{{- if .SignupNativeFailed}}
<p class="form-hint" id="signup-native-failed">{{T "app_info.signup_native_failed"}}</p>
{{- end}}
{{- if .SignupNativeOpenFailed}}
<p class="form-hint" id="signup-native-open-failed">{{T "app_info.signup_native_open_failed"}}</p>
{{- end}}
{{- if .SignupClosed}}
{{- if .SignupNative}}
<p class="form-hint">{{T "app_info.signup_window_restart"}}</p>
{{- end}}
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/signup-window')">{{T "app_info.signup_window_btn"}}</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
{{- end}}
</div>
{{- end}}
{{if .DataPathCards}}
<div class="app-info-card" style="margin-top:1rem">
<h3>{{T "app_info.hova_tegyem_a_fajlokat"}}</h3>
<p class="form-hint">{{T "app_info.ezeket_a_mappakat_a_fajlkezeloben"}}</p>
<div class="datapath-list">
{{range .DataPathCards}}
<div class="datapath-row">
<div class="datapath-head">
<a href="{{.Link}}" target="_blank" rel="noopener" class="datapath-link">{{.Label}} ↗</a>
{{if .FreeSpace}}<span class="datapath-space">{{.FreeSpace}}</span>{{end}}
</div>
{{if .Consequence}}<p class="datapath-note">{{.Consequence}}</p>{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
{{if and .Stack.Deployed .MigrateTargets}}
<div class="app-info-card" style="margin-top:1rem">
<h3>{{T "app_info.athelyezes_masik_tarhelyre"}}</h3>
<p class="form-hint">{{T "app_info.ennek_az_alkalmazasnak_az_adatait"}}</p>
<div style="display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;margin-top:.5rem">
<select id="app-migrate-target" class="btn btn-sm btn-outline">
<option value="">{{T "app_info.valassz_celtarhelyet"}}</option>
{{range .MigrateTargets}}<option value="{{.Path}}">{{.Label}} ({{.Path}})</option>{{end}}
</select>
<button class="btn btn-sm btn-outline" onclick="appMigrate(this,'{{.Stack.Name}}','{{.Meta.DisplayName}}')">{{T "app_info.athelyezes"}}</button>
</div>
<div id="app-migrate-progress" style="display:none;margin-top:.75rem;padding:.75rem;border:1px solid var(--blue);border-radius:6px;background:rgba(0,136,204,0.06)"></div>
</div>
<script>
function appMigFmtGB(b){ return fhNum(Number(b||0)/1e9, 1)+' GB'; }
function appMigRender(job){
var names={stop:'{{T "app_info.leallitas"}}',copy:'{{T "app_info.adatok_masolasa"}}',verify:'{{T "app_info.ellenorzes"}}',flip:'{{T "app_info.ujratelepites"}}',redeploy:'{{T "app_info.ujratelepites"}}',cleanup:'{{T "app_info.regi_adatok_torlese"}}'};
var s=names[job.phase]||job.phase;
if(job.phase==='copy'&&job.bytes_total>0){ s+=' ('+Math.floor(100*job.bytes_done/job.bytes_total)+'% — '+appMigFmtGB(job.bytes_done)+'/'+appMigFmtGB(job.bytes_total)+')'; }
return s+'…';
}
function appMigWatch(){
var panel=document.getElementById('app-migrate-progress');
if(panel) panel.style.display='block';
function tick(){
fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){
var job=d.data&&d.data.job;
if(!job){ if(panel) panel.textContent='{{T "app_info.nincs_folyamatban_athelyezes"}}'; return; }
if(panel) panel.innerHTML=appMigRender(job);
if(job.phase==='done'){ if(panel) panel.innerHTML+='<br><strong>{{T "app_info.kesz"}}</strong>'; setTimeout(function(){location.reload();},1500); return; }
if(job.phase==='aborted'){ if(panel) panel.innerHTML+='<br><strong style="color:var(--danger,#c0392b)">{{T "app_info.megszakadt"}} '+(job.error||'')+'</strong><br>{{T "app_info.a_regi_adatok_erintetlenek"}}'; return; }
setTimeout(tick,1500);
}).catch(function(){ setTimeout(tick,2000); });
}
tick();
}
function appMigrate(btn,app,label){
var sel=document.getElementById('app-migrate-target');
var target=sel?sel.value:'';
if(!target){ showAlert('{{T "app_info.valassz_celtarhelyet_2"}}'); return; }
felhomConfirm(btn,'{{T "app_info.athelyezed_a_z"}} '+label+' {{T "app_info.adatait_ide"}} '+target+'{{T "app_info.az_alkalmazas_rovid_idore_leall"}}',function(){
fetch('/api/storage/migrate-app',{method:'POST',headers:Object.assign({'Content-Type':'application/json'},csrfHeaders()),body:JSON.stringify({app:app,target:target})})
.then(function(r){return r.json();}).then(function(d){ if(d.ok){ appMigWatch(); } else { showAlert('{{T "common.hiba_kettospont"}} '+(d.error||'{{T "app_info.ismeretlen"}}')); } })
.catch(function(e){ showAlert('{{T "common.hiba_kettospont"}} '+e); });
});
}
(function(){ fetch('/api/storage/migrate/status').then(function(r){return r.json();}).then(function(d){ if(d.data&&d.data.job){ appMigWatch(); } }).catch(function(){}); })();
</script>
{{end}}
{{if .HasAppInfo}}
<div class="app-info-grid">
{{if .AppInfo.UseCases}}
<div class="app-info-card">
<h3>{{T "app_info.mire_hasznalhato"}}</h3>
<ul class="app-info-list">
{{range .AppInfo.UseCases}}<li>{{.}}</li>{{end}}
</ul>
</div>
{{end}}
{{if .AppInfo.FirstSteps}}
<div class="app-info-card">
<h3>{{T "app_info.elso_lepesek"}}</h3>
<ol class="app-info-list">
{{range .AppInfo.FirstSteps}}<li>{{.}}</li>{{end}}
</ol>
</div>
{{end}}
{{if .AppInfo.Prerequisites}}
<div class="app-info-card">
<h3>{{T "app_info.elofeltetelek"}}</h3>
<ul class="app-info-list">
{{range .AppInfo.Prerequisites}}<li>{{.}}</li>{{end}}
</ul>
</div>
{{end}}
{{if .InitialCreds}}
<div class="app-info-card">
<h3>{{T "app_info.kezdeti_belepesi_adatok"}}</h3>
<table class="initcred-table">
{{if .InitialCreds.Username}}
<tr>
<td class="initcred-label" style="padding:.25rem .75rem .25rem 0;color:var(--text-3);white-space:nowrap">{{T "app_info.felhasznalonev"}}</td>
<td><code class="initcred-user" style="user-select:all">{{.InitialCreds.Username}}</code></td>
</tr>
{{end}}
{{if .InitialCredsOperatorSet}}
<tr>
<td class="initcred-label" style="padding:.25rem .75rem .25rem 0;color:var(--text-3);white-space:nowrap">{{T "common.jelszo"}}</td>
<td>{{T "app_info.az_uzemelteto_allitotta_be"}}</td>
</tr>
{{else}}
<tr>
<td class="initcred-label" style="padding:.25rem .75rem .25rem 0;color:var(--text-3);white-space:nowrap;vertical-align:middle">{{T "common.jelszo"}}</td>
<!-- R-254: the value is NOT in this page. It used to be rendered into a `hidden`
span, which stops a browser drawing it and nothing else — a fetch of this page
returned a real per-install password. Both buttons now ask the server. -->
<td style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<code id="initcred-pw">••••••••••••</code>
<button type="button" class="btn btn-sm btn-outline" id="initcred-reveal" onclick="icRevealPw(this)">{{T "app_info.megjelenites"}}</button>
<button type="button" class="btn btn-sm btn-outline" onclick="icCopyPw(this)">{{T "app_info.masolas"}}</button>
<span id="initcred-err" class="form-hint" style="display:none;color:var(--red)"></span>
</td>
</tr>
{{end}}
</table>
{{if .InitialCreds.Note}}<p class="app-info-creds-warn">{{.InitialCreds.Note}}</p>{{end}}
{{if not .InitialCredsOperatorSet}}<p class="app-info-creds-warn">{{T "app_info.az_elso_bejelentkezes_utan_azonnal"}}</p>{{end}}
</div>
{{end}}
{{if and .AppInfo.DefaultCreds (not .DefaultLoginReplaced)}}
<div class="app-info-card">
<h3>{{T "app_info.alapertelmezett_belepes"}}</h3>
<p class="app-info-creds">{{.AppInfo.DefaultCreds}}</p>
{{if .KnownLoginLine}}<p class="app-info-creds-warn" id="known-login-line">{{.KnownLoginLine}}</p>{{else}}<p class="app-info-creds-warn">{{T "app_info.az_elso_bejelentkezes_utan_azonnal_2"}}</p>{{end}}
{{- if and .Stack.Deployed .KnownLoginLine}}
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/default-login/changed')">{{T "app_info.default_login_changed_btn"}}</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
{{- end}}
</div>
{{end}}
{{if .AppInfo.DocsURL}}
<div class="app-info-card">
<h3>{{T "app_info.dokumentacio"}}</h3>
<p><a href="{{.AppInfo.DocsURL}}" target="_blank" class="app-info-link">{{T "app_info.hivatalos_dokumentacio"}}</a></p>
</div>
{{end}}
</div>
{{end}}
{{if .InitialCreds}}
<script>
// R-254: the password is NOT in this page. It is fetched on demand from the server, which re-reads it
// live from the running container — so this is the only way it reaches a browser, and every fetch is
// recorded server-side. Nothing caches it in a variable between presses: each act asks again.
function icFetchPw() {
return fetch('/apps/{{.Meta.Slug}}/initial-credentials/reveal', {
method: 'POST',
headers: {'X-CSRF-Token': '{{.CSRFToken}}'},
credentials: 'same-origin'
}).then(function (r) { return r.json(); }).then(function (j) {
if (!j.ok) { throw new Error(j.error || '{{T "app_info.a_kezdeti_jelszo_beolvasasa_nem"}}'); }
return j.data.password;
});
}
function icErr(msg) {
var e = document.getElementById('initcred-err');
e.textContent = msg;
e.style.display = 'inline';
}
function icRevealPw(btn) {
var code = document.getElementById('initcred-pw');
if (!code) return;
document.getElementById('initcred-err').style.display = 'none';
if (code.dataset.shown === '1') { // hide: drop the value out of the DOM again
code.textContent = '••••••••••••';
code.dataset.shown = '0';
btn.textContent = '{{T "app_info.megjelenites"}}';
return;
}
btn.disabled = true;
icFetchPw().then(function (pw) {
btn.disabled = false;
code.textContent = pw;
code.dataset.shown = '1';
btn.textContent = '{{T "app_info.elrejtes"}}';
}).catch(function (e) { btn.disabled = false; icErr(e.message); });
}
function icCopyPw(btn) {
document.getElementById('initcred-err').style.display = 'none';
btn.disabled = true;
icFetchPw().then(function (pw) {
return navigator.clipboard.writeText(pw).then(function () {
btn.disabled = false;
var orig = btn.textContent;
btn.textContent = '{{T "app_info.masolva"}}';
setTimeout(function () { btn.textContent = orig; }, 1500);
});
}).catch(function (e) { btn.disabled = false; icErr(e.message); });
}
</script>
{{end}}
{{template "layout_end" .}}
{{- if or .SetupGateClosed .SignupClosed .CloseSignupOffered (and .Stack.Deployed .KnownLoginLine) .LostFiles}}
<script>
// v0.280.0: the household's presses on this page — "Done, I set it up" (decision 46), "I changed it" (R-710).
// R-1028: the household's Plex code, given to the RUNNING server; the gate opens when Plex says it is claimed.
function plexClaim(btn) {
var box = btn.closest('.plex-claim'), err = box.querySelector('.app-press-err');
var code = (document.getElementById('plex-claim-code') || {}).value || '';
err.style.display = 'none'; btn.disabled = true;
fetch('/apps/plex/plex-claim', {method: 'POST', headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()), credentials: 'same-origin', body: JSON.stringify({code: code.trim()})})
.then(function (r) { return r.json(); })
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
}
function appPress(btn, url) {
var err = btn.parentNode.querySelector('.app-press-err');
if (err) { err.style.display = 'none'; }
btn.disabled = true;
fetch(url, {method: 'POST', headers: csrfHeaders(), credentials: 'same-origin'})
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.ok) { throw new Error(j.error || ''); }
window.location.reload();
})
.catch(function (e) { btn.disabled = false; if (err) { err.textContent = e.message; err.style.display = 'inline'; } });
}
</script>
{{- end}}
{{end}}
@@ -1,9 +1,12 @@
{{define "logs"}}
{{define "app_logs"}}
{{template "layout_start" .}}
<div class="page-header">
<a href="/stacks" class="btn btn-sm btn-outline">{{T "logs.vissza"}}</a>
<h2>{{T "logs.naplok"}}</h2>
</div>
{{template "app_shell_start" .}}
{{/* „NAPLO" — today's logs page inside the shell. Its live poller runs ONLY on this tab, which is
the reason the tabs are four URLs and not four hidden panels: a poller on the settings tab
would ask the box for 200 log lines every three seconds for nothing. */}}
<div data-tab-panel="logs">
<div class="logs-container" id="logs-container">
<pre class="logs-output" id="logs-output">{{.Logs}}</pre>
</div>
@@ -22,14 +25,14 @@
var toggleBtn = document.getElementById('logs-toggle');
var live = true;
var timer = null;
var stackName = '{{.Stack.Name}}';
var appSlug = '{{.Meta.Slug}}';
function isAtBottom() {
return container.scrollHeight - container.scrollTop - container.clientHeight < 50;
}
window.fetchLogs = function() {
fetch('/stacks/' + stackName + '/logs?raw=1')
fetch('/apps/' + appSlug + '/logs?raw=1')
.then(function(r) { return r.text(); })
.then(function(text) {
var wasAtBottom = isAtBottom();
@@ -67,6 +70,7 @@
container.scrollTop = container.scrollHeight;
startPolling();
})();
</script>
</div>
{{template "layout_end" .}}
{{end}}
@@ -0,0 +1,715 @@
{{define "app_settings"}}
{{template "layout_start" .}}
{{template "app_shell_start" .}}
{{/* „BEALLITASOK" — the tab an installed app opens on.
Order, and each step of it is a decision:
1. Mukodes — Start / Restart / Stop. They used to sit in the page header and read as
menu buttons; a state change is an action on the app, so it is a card.
2. what the app still wants FROM the household — the setup gate, an after-start value, the
sign-up window, the first login. Above the form, because an app that is waiting on the
household is not asking them to review settings.
3. the settings themselves — generated values, install-time values (read-only), the optional
ones, e-mail, integrations, geo.
„Adatok es mentes" holds everything about the FILES, including the backup card that used to
sit on this page. */}}
<div class="deploy-container">
{{if .FlashSuccess}}<div class="flash flash-success">{{.FlashSuccess}}</div>{{end}}
{{if .FlashError}}<div class="flash flash-error">{{.FlashError}}</div>{{end}}
{{/* 1. MUKODES. Stop asks first — it takes the app away from everyone in the house, and the
protected stacks refuse it server-side regardless (traefik, cloudflared, the controller
itself and always samba). */}}
<div class="app-info-card" id="app-run-card" data-run-card="1">
<h3>{{T "app_page.mukodes"}}</h3>
<p class="form-hint" data-run-state="{{.Stack.State}}">{{stateLabel .Stack.State}}</p>
<div style="display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.5rem">
{{if isOperational .Stack.State}}
<button class="btn btn-sm btn-warning" data-run-action="restart" onclick="stackAction(event, '{{.Stack.Name}}', 'restart')">{{T "common.ujrainditas"}}</button>
<button class="btn btn-sm btn-danger" data-run-action="stop" onclick="var b=this; felhomConfirm(b, '{{T "app_page.stop_confirm"}}', function(){ stackAction(event, '{{$.Stack.Name}}', 'stop'); })">{{T "deploy.leallitas"}}</button>
{{else}}
<button class="btn btn-sm btn-success" data-run-action="start" onclick="stackAction(event, '{{.Stack.Name}}', 'start')">{{T "deploy.inditas"}}</button>
{{end}}
</div>
</div>
{{/* 2. WHAT THE APP STILL WANTS FROM THE HOUSEHOLD. */}}
{{- if .InstallHoldClosed}}
<div class="app-info-card" style="margin-top:1rem" id="install-hold-card">
<h3>{{T "app_info.install_hold_title"}}</h3>
<p>{{T "app_info.install_hold_closed"}}</p>
</div>
{{- end}}
{{- if .SetupGateClosed}}
<div class="app-info-card" style="margin-top:1rem" id="setup-gate-card">
<h3>{{T "app_info.setup_gate_title"}}</h3>
<p>{{T "app_info.setup_gate_closed"}}</p>{{- if .SignupNative}}
<p class="form-hint" data-restarts-once="true">{{T "app_info.restarts_once"}}</p>{{- end}}
{{- if .SetupGateHasProbe}}
<p class="form-hint">{{T "app_info.setup_gate_probe"}}</p>
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/setup-gate/open')">{{T "app_info.setup_gate_done_btn"}}</button>
{{- else}}
<p class="form-hint">{{T "app_info.setup_gate_button_hint"}}</p>
<button type="button" class="btn btn-sm btn-primary" onclick="var b=this; felhomConfirm(b, '{{T "app_info.setup_gate_confirm"}}', function(){ appPress(b, '/apps/{{.Meta.Slug}}/setup-gate/open'); })">{{T "app_info.setup_gate_done_btn"}}</button>
{{- end}}
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
{{- end}}
{{/* The generic after-start card (Part 4, D2 = A). The catalog says a value is asked after the
app starts and supplies its label; the ACTION is Go code in after_start.go, keyed by slug and
env var. A field with no registered action renders nothing and POSTs 404 — the catalog
cannot make the box run anything. */}}
{{- range .AfterStartFields}}
<div class="app-info-card after-start-card" data-afterstart="{{.EnvVar}}">
<h3>{{T "app_page.after_start_title"}}</h3>
{{if .Description}}<p>{{.Description}}</p>{{end}}
<div class="input-with-button">
<input type="text" id="after-start-{{.EnvVar}}" class="form-control"
placeholder="{{.Placeholder}}" autocomplete="off" spellcheck="false"
aria-label="{{.Label}}">
<button type="button" class="btn btn-sm btn-primary"
onclick="appAfterStart(this, '{{$.Meta.Slug}}', '{{.EnvVar}}')">{{T "app_page.after_start_btn"}}</button>
</div>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
{{- end}}
{{- if .CloseSignupOffered}}
<div class="app-info-card" style="margin-top:1rem" id="close-signup-card">
<h3>{{T "app_info.signup_title"}}</h3>
<p>{{T "app_info.close_signup_text"}}</p>{{- if .SignupNative}}
<p class="form-hint" data-restarts-once="true">{{T "app_info.restarts_once"}}</p>{{- end}}
<button type="button" class="btn btn-sm btn-primary" onclick="appPress(this, '/apps/{{.Meta.Slug}}/close-signup')">{{T "app_info.close_signup_btn"}}</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
{{- end}}
{{- if or .SignupClosed .SignupOpenUntil}}
<div class="app-info-card" style="margin-top:1rem" id="signup-card">
<h3>{{T "app_info.signup_title"}}</h3>
{{- if .SignupOpenUntil}}
<p>{{T "app_info.signup_open_until"}} {{.SignupOpenUntil}}</p>
{{- else}}
<p>{{T "app_info.signup_closed"}}</p>
{{- end}}
{{- if .AppInfo.AddPeople}}
<p class="form-hint" id="add-people">{{T "app_info.signup_add_how"}} {{.AppInfo.AddPeople}}</p>
{{- end}}
{{- if .SignupNativeFailed}}
<p class="form-hint" id="signup-native-failed">{{T "app_info.signup_native_failed"}}</p>
{{- end}}
{{- if .SignupNativeOpenFailed}}
<p class="form-hint" id="signup-native-open-failed">{{T "app_info.signup_native_open_failed"}}</p>
{{- end}}
{{- if .SignupClosed}}
{{- if .SignupNative}}
<p class="form-hint">{{T "app_info.signup_window_restart"}}</p>
{{- end}}
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/signup-window')">{{T "app_info.signup_window_btn"}}</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
{{- end}}
</div>
{{- end}}
{{/* The logins, each already its own guarded card on the details page. */}}
<div class="app-info-grid">
{{if .InitialCreds}}
<div class="app-info-card">
<h3>{{T "app_info.kezdeti_belepesi_adatok"}}</h3>
<table class="initcred-table">
{{if .InitialCreds.Username}}
<tr>
<td class="initcred-label" style="padding:.25rem .75rem .25rem 0;color:var(--text-3);white-space:nowrap">{{T "app_info.felhasznalonev"}}</td>
<td><code class="initcred-user" style="user-select:all">{{.InitialCreds.Username}}</code></td>
</tr>
{{end}}
{{if .InitialCredsOperatorSet}}
<tr>
<td class="initcred-label" style="padding:.25rem .75rem .25rem 0;color:var(--text-3);white-space:nowrap">{{T "common.jelszo"}}</td>
<td>{{T "app_info.az_uzemelteto_allitotta_be"}}</td>
</tr>
{{else}}
<tr>
<td class="initcred-label" style="padding:.25rem .75rem .25rem 0;color:var(--text-3);white-space:nowrap;vertical-align:middle">{{T "common.jelszo"}}</td>
<!-- R-254: the value is NOT in this page. It used to be rendered into a `hidden`
span, which stops a browser drawing it and nothing else — a fetch of this page
returned a real per-install password. Both buttons now ask the server. -->
<td style="display:flex;align-items:center;gap:.5rem;flex-wrap:wrap">
<code id="initcred-pw">••••••••••••</code>
<button type="button" class="btn btn-sm btn-outline" id="initcred-reveal" onclick="icRevealPw(this)">{{T "app_info.megjelenites"}}</button>
<button type="button" class="btn btn-sm btn-outline" onclick="icCopyPw(this)">{{T "app_info.masolas"}}</button>
<span id="initcred-err" class="form-hint" style="display:none;color:var(--red)"></span>
</td>
</tr>
{{end}}
</table>
{{if .InitialCreds.Note}}<p class="app-info-creds-warn">{{.InitialCreds.Note}}</p>{{end}}
{{if not .InitialCredsOperatorSet}}<p class="app-info-creds-warn">{{T "app_info.az_elso_bejelentkezes_utan_azonnal"}}</p>{{end}}
</div>
{{end}}
{{if and .AppInfo.DefaultCreds (not .DefaultLoginReplaced)}}
<div class="app-info-card">
<h3>{{T "app_info.alapertelmezett_belepes"}}</h3>
<p class="app-info-creds">{{.AppInfo.DefaultCreds}}</p>
{{if .KnownLoginLine}}<p class="app-info-creds-warn" id="known-login-line">{{.KnownLoginLine}}</p>{{else}}<p class="app-info-creds-warn">{{T "app_info.az_elso_bejelentkezes_utan_azonnal_2"}}</p>{{end}}
{{- if and .Stack.Deployed .KnownLoginLine}}
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/default-login/changed')">{{T "app_info.default_login_changed_btn"}}</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
{{- end}}
</div>
{{end}}</div>
{{/* 3. THE SETTINGS. Everything below came from the install page's deployed mode, unchanged. */}}
<div class="alert alert-info">
{{T "deploy.ez_az_alkalmazas_mar_telepitve"}}
</div>
{{if .StorageInfo}}
<div class="deploy-storage-info">
<h4>{{T "deploy.adattarolas"}}</h4>
<div class="settings-grid">
<div class="settings-row">
<span class="settings-label">{{T "deploy.tarhely"}}</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">{{T "deploy.adatmeret"}}</span>
<span class="settings-value mono">{{numText .StorageInfo.DataSizeHuman}}</span>
</div>
{{end}}
{{if .StorageInfo.FreeHuman}}
<div class="settings-row">
<span class="settings-label">{{T "deploy.szabad_hely"}}</span>
<span class="settings-value mono">{{T "deploy.szabad"}}</span>
</div>
{{end}}
</div>
{{if .OtherStoragePaths}}
<span class="btn btn-sm btn-outline" style="margin-top:.75rem;opacity:.45;cursor:not-allowed" title="{{T "deploy.hamarosan"}}">
<svg class="ico ico-sm"><use href="#i-upload"/></svg> {{T "deploy.mozgatas_masik_tarolora"}}
</span>
{{end}}
</div>
{{end}}
{{if .StaleData}}
<div class="deploy-stale-data">
<h4>{{T "deploy.korabbi_adatok"}}</h4>
<p class="form-hint" style="margin-bottom:1rem">
{{T "deploy.az_alkalmazas_adatainak_masolata_megtala"}}
</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 "deploy.tarhely"}}</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">{{T "deploy.meret"}}</span>
<span class="settings-value mono">{{numText .SizeHuman}}</span>
</div>
<div class="settings-row">
<span class="settings-label">{{T "deploy.mappak"}}</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)">
{{T "deploy.korabbi_adatok_torlese"}}
</button>
</div>
{{end}}
</div>
{{end}}
{{if .HasIntegrations}}
<div class="app-optional-config">
<h3>{{T "deploy.integraciok"}}</h3>
<p class="config-group-desc">
{{T "deploy.mas_telepitett_alkalmazasokkal_valo_ossz"}}
</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>{{T "deploy.nincs_telepitve"}}</span>
{{else if not .TargetRunning}}
<span class="tag tag-neutral" style="margin-top:.25rem"><span class="dot"></span>{{T "deploy.celalkalmazas_leallitva"}}</span>
{{else if eq .Status "error"}}
<span class="tag tag-crit" style="margin-top:.25rem"><span class="dot"></span>{{T "common.hiba"}}</span>
{{else if .Enabled}}
<span class="tag tag-run" style="margin-top:.25rem"><span class="dot"></span>{{T "deploy.aktiv"}}</span>
{{end}}
</div>
<label class="toggle">
<input type="checkbox"
{{if .Enabled}}checked{{end}}
{{if not .TargetDeployed}}disabled title="{{T "deploy.a_celalkalmazas_nincs_telepitve"}}"{{end}}
{{if and .TargetDeployed (not .TargetRunning)}}disabled title="{{T "deploy.a_celalkalmazas_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 || '{{T "common.hiba_tortent"}}');
} else {
setTimeout(function(){ location.reload(); }, 500);
return;
}
} catch(err) {
checkbox.checked = !enable;
showAlert('{{T "deploy.halozati_hiba"}}');
}
checkbox.disabled = false;
}
</script>
{{end}}
{{if .GeoGlobalEnabled}}
<div class="app-optional-config">
<h3>{{T "deploy.foldrajzi_korlatozas"}}</h3>
<p class="config-group-desc">
{{T "deploy.az_alkalmazas_egyeni_orszagkorlatozas_ne"}}
</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">{{T "deploy.egyeni_orszagkorlatozas"}}</span>
</label>
<div id="app-geo-override-details" {{if not .GeoAppOverride}}style="display:none"{{end}}>
<div class="form-group">
<label>{{T "deploy.engedelyezett_orszagok_ehhez_az_alkalmaz"}}</label>
<div class="geo-country-selector">
<input type="text" id="app-geo-search" class="form-control config-input"
placeholder="{{T "deploy.orszag_keresese"}}" 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()">{{T "deploy.mentes"}}</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">{{T "deploy.nincs_talalat"}}</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 || '{{T "deploy.mentve"}}') : (d.error || '{{T "common.hiba"}}');
status.className = 'config-save-status ' + (d.ok ? 'config-save-ok' : 'config-save-err');
setTimeout(function(){ status.textContent=''; }, 5000);
})
.catch(function(){
status.textContent = '{{T "deploy.halozati_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>{{T "deploy.opcionalis_beallitasok"}}</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">{{T "deploy.regisztracios_utmutato"}}</a></p>{{end}}
{{if eq .Type "secret_input"}}
{{/* R-255 (v0.308.0): a stored secret is NOT in the page; it is fetched on demand like the
auto-generated ones (R-254). A blank field that was never revealed is not sent, so
saving other fields keeps it. */}}
{{$set := index $.CurrentValues .EnvVar}}
<div class="input-with-button">
<input type="password" id="opt-{{.EnvVar}}" name="{{.EnvVar}}" class="config-input"
value="" placeholder="{{if $set}}••••••••••••{{else}}{{.Label}}{{end}}"
{{if $set}}data-secret-set="1" {{end}}autocomplete="off">
{{if $set}}<button type="button" class="btn btn-sm btn-outline" onclick="revealAutoField('{{$.Stack.Name}}','{{.EnvVar}}', this, 'opt-{{.EnvVar}}')">{{T "deploy.megjelenites"}}</button>{{end}}
</div>
{{else}}
<input type="text"
id="opt-{{.EnvVar}}"
name="{{.EnvVar}}"
class="config-input"
value="{{index $.CurrentValues .EnvVar}}"
placeholder="{{.Label}}"
autocomplete="off">
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
<div class="config-actions">
<button class="btn btn-primary" id="save-optional-config" onclick="saveOptionalConfig('{{.Stack.Name}}')">
{{T "deploy.mentes"}}
</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) {
// R-255: a stored secret the household never revealed and left blank stays as it is.
if (input.dataset.secretSet === '1' && input.dataset.revealed !== '1' && input.value === '') return;
values[input.name] = input.value;
});
btn.disabled = true;
btn.textContent = '{{T "deploy.mentes_2"}}';
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 || '{{T "deploy.mentve"}}');
status.className = 'config-save-status config-save-ok';
} else {
status.textContent = (data.error || '{{T "common.hiba"}}');
status.className = 'config-save-status config-save-err';
}
} catch(err) {
status.textContent = '{{T "deploy.halozati_hiba"}}';
status.className = 'config-save-status config-save-err';
}
btn.disabled = false;
btn.textContent = '{{T "deploy.mentes"}}';
setTimeout(function() { status.textContent = ''; }, 5000);
}
</script>
{{end}}
{{if .AppEmailSupported}}
<div class="app-optional-config">
<h3>{{T "deploy.email_kuldes"}}</h3>
<p class="config-group-desc">
{{T "deploy.ez_az_alkalmazas_tud_emailt"}}
</p>
{{if not .AppEmailGlobalOn}}
<p class="alert alert-warning" style="margin-bottom:1rem">
{{T "deploy.az_alkalmazas_email_jelenleg_ki"}}
</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}}>
{{T "deploy.email_kuldes_engedelyezese_ennel_az"}}
</label>
<div class="config-actions">
<button class="btn btn-primary" type="submit">{{T "deploy.mentes"}}</button>
<span class="config-group-desc" style="margin-left:.5rem">{{T "deploy.a_mentes_ujrainditja_az_alkalmazast"}}</span>
</div>
</form>
</div>
{{end}}
<form id="deploy-form" class="deploy-form">
{{template "app_deploy_fields" .}}
</form>
</div>
{{if .InitialCreds}}
<script>
// R-254: the password is NOT in this page. It is fetched on demand from the server, which re-reads it
// live from the running container — so this is the only way it reaches a browser, and every fetch is
// recorded server-side. Nothing caches it in a variable between presses: each act asks again.
function icFetchPw() {
return fetch('/apps/{{.Meta.Slug}}/initial-credentials/reveal', {
method: 'POST',
headers: {'X-CSRF-Token': '{{.CSRFToken}}'},
credentials: 'same-origin'
}).then(function (r) { return r.json(); }).then(function (j) {
if (!j.ok) { throw new Error(j.error || '{{T "app_info.a_kezdeti_jelszo_beolvasasa_nem"}}'); }
return j.data.password;
});
}
function icErr(msg) {
var e = document.getElementById('initcred-err');
e.textContent = msg;
e.style.display = 'inline';
}
function icRevealPw(btn) {
var code = document.getElementById('initcred-pw');
if (!code) return;
document.getElementById('initcred-err').style.display = 'none';
if (code.dataset.shown === '1') { // hide: drop the value out of the DOM again
code.textContent = '••••••••••••';
code.dataset.shown = '0';
btn.textContent = '{{T "app_info.megjelenites"}}';
return;
}
btn.disabled = true;
icFetchPw().then(function (pw) {
btn.disabled = false;
code.textContent = pw;
code.dataset.shown = '1';
btn.textContent = '{{T "app_info.elrejtes"}}';
}).catch(function (e) { btn.disabled = false; icErr(e.message); });
}
function icCopyPw(btn) {
document.getElementById('initcred-err').style.display = 'none';
btn.disabled = true;
icFetchPw().then(function (pw) {
return navigator.clipboard.writeText(pw).then(function () {
btn.disabled = false;
var orig = btn.textContent;
btn.textContent = '{{T "app_info.masolva"}}';
setTimeout(function () { btn.textContent = orig; }, 1500);
});
}).catch(function (e) { btn.disabled = false; icErr(e.message); });
}
</script>
{{end}}
{{template "app_press_script" .}}
<script>
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 revealPasswordField(stackName, envVar, btn) {
var el = document.getElementById('field-' + envVar);
if (!el) return;
if (btn.dataset.shown === '1') {
el.value = '';
el.type = 'password';
btn.dataset.shown = '';
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 || '{{T "deploy.a_lekeres_nem_sikerult"}}'); return; }
el.value = j.data.value;
el.type = 'text';
el.dataset.revealed = '1';
btn.dataset.shown = '1';
}).catch(function () { btn.disabled = false; showAlert('{{T "deploy.a_lekeres_nem_sikerult"}}'); });
}
function revealAutoField(stackName, envVar, btn, elId) {
var el = document.getElementById(elId || ('auto-field-' + envVar));
if (!el) return;
if (btn.dataset.shown === '1') {
el.value = '';
el.type = 'password';
el.dataset.revealed = '';
btn.dataset.shown = '';
btn.textContent = '{{T "deploy.megjelenites"}}';
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 || '{{T "deploy.a_lekeres_nem_sikerult"}}'); return; }
el.value = j.data.value;
el.type = 'text';
btn.dataset.shown = '1';
btn.textContent = '{{T "deploy.elrejtes"}}';
}).catch(function () { btn.disabled = false; showAlert('{{T "deploy.a_lekeres_nem_sikerult"}}'); });
}
function deleteStaleData(stackName, stalePath, btn) {
// Two-step inline confirm (the irreversible delete keeps its double acknowledgement).
felhomConfirm(btn, '{{T "deploy.biztosan_torolni_szeretned_a_korabbi"}} ' + stalePath + ' {{T "deploy.figyelem_a_muvelet_visszavonhatatlan_elo"}}', function () {
felhomConfirm(btn, '{{T "deploy.utolso_figyelmeztetes_a_torles_visszavon"}}', function () {
deleteStaleDataRun(stackName, stalePath, btn);
});
});
}
function deleteStaleDataRun(stackName, stalePath, btn) {
btn.disabled = true;
btn.textContent = '{{T "deploy.torles_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('{{T "common.hiba_kettospont"}} ' + (data.error || '{{T "deploy.ismeretlen_hiba"}}'));
btn.disabled = false;
btn.textContent = '{{T "deploy.korabbi_adatok_torlese"}}';
return;
}
var msg = '{{T "deploy.korabbi_adatok_torolve_n_nfelszabaditott"}} ' + (data.freed_human || '?');
if (data.errors && data.errors.length > 0) {
msg += '{{T "deploy.n_nnehany_hiba_tortent_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('{{T "deploy.halozati_hiba_2"}} ' + e.message);
btn.disabled = false;
btn.textContent = '{{T "deploy.korabbi_adatok_torlese"}}';
});
}
</script>
{{template "layout_end" .}}
{{end}}
@@ -0,0 +1,137 @@
{{/* THE APP PAGE'S SHARED SHELL (v0.315.0).
One header and one set of banners for all four tabs, so a held app says it is held on the log
tab too and the household never has to go back to the details page to find out why.
Order is load-bearing and is the alert-placement rule (R-571), unchanged: the page-level
banners sit ABOVE the tab bar, because they are about the APP, not about the tab. An inline
alert belongs inside the tab that owns it.
The header carries NO Start/Restart/Stop. They used to sit here and read as menu buttons; they
are now the first card of the „Beallitasok" tab, where a state change belongs. */}}
{{define "app_shell_start"}}
<div class="page-header">
<div style="display:flex;align-items:center;gap:1rem">
<a href="/stacks" class="btn btn-sm btn-outline">{{T "app_info.alkalmazasok"}}</a>
<h2>{{.Meta.DisplayName}}</h2>
</div>
<div style="display:flex;align-items:center;gap:.5rem">
{{if .Deployed}}
<span class="stack-state-badge state-{{stateColor .Stack.State}}">{{stateLabel .Stack.State}}</span>
{{if .Stack.Orphaned}}<span class="badge badge-orphaned">{{T "common.elavult"}}</span>{{end}}
{{template "meta_badge" (lifecycleBadge .Meta)}}
{{template "meta_badge" (updateBadge .Stack)}}{{if and (gt .Stack.LadderStepsLeft 0) (not .Stack.HoldReason)}}<small class="text-muted" data-ladder-steps="{{.Stack.LadderStepsLeft}}">{{T "app_info.ladder_steps_left"}} {{.Stack.LadderStepsLeft}}</small>{{end}}
{{if and .AppOperational .EffectiveSubdomain}}<a href="https://{{.EffectiveSubdomain}}.{{.Domain}}{{.Meta.OpenPath}}" target="_blank" class="btn btn-sm btn-primary" data-app-open="1">{{T "app_info.megnyitas"}}</a>{{end}}
{{if .Stack.Orphaned}}<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Stack.Name}}')">{{T "common.torles"}}</button>{{end}}
{{end}}
</div>
</div>
<div class="app-hero">
<img class="app-hero-logo" src="{{logoURL .Meta.Slug}}"
alt="{{.Meta.DisplayName}}" data-fallback="/static/app-placeholder.svg"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Meta.Slug}}';}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">
{{if .AppInfo.Tagline}}<p class="app-info-tagline">{{.AppInfo.Tagline}}</p>{{else}}<p class="app-info-tagline">{{.Meta.Description}}</p>{{end}}
<div class="stack-meta-badges">
<span class="meta-badge">~{{.Meta.Resources.MemRequest}} RAM</span>
<span class="meta-badge">{{.Meta.Category}}</span>
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge meta-badge-warn">{{T "common.hdd_szukseges"}}</span>{{end}}
{{if .Meta.Resources.HungarianUI}}<span class="meta-badge meta-badge-ok">{{T "common.magyar_felulet"}}</span>{{end}}
</div>
</div>
</div>
{{template "app_banners" .}}
{{template "app_tab_bar" .}}
{{end}}
{{/* app_banners — every page-level alert the app can carry, on EVERY tab. Each keeps the marker it
had on the details page, so the tests and the operator's greps still find it. */}}
{{define "app_banners"}}
{{- if .Meta.IsAbandoned}}
<div class="alert alert-warning" style="margin-top:.75rem">{{T "app_info.az_alkalmazas_fejlesztoje_felhagyott_a"}}</div>
{{- end}}
{{- if .LostFiles}}
<div class="app-info-card alert-warning" style="margin-top:1rem" id="lost-files-card">
<h3>{{T "app_info.lost_files_title"}}</h3>
<p>{{T "app_info.lost_files_text"}}{{if .LostBootAt}} <strong data-lost-boot-at>{{.LostBootAt}}</strong>{{end}}</p>
<ul data-lost-files>
{{- range .LostFiles}}
<li><span class="mono">{{.Name}}</span>{{if .Taken}} — {{T "app_info.lost_files_taken"}} {{.Taken}}{{end}}</li>
{{- end}}
</ul>
<p>{{T "app_info.lost_files_hint"}}</p>
<button type="button" class="btn btn-sm btn-outline" onclick="appPress(this, '/apps/{{.Meta.Slug}}/lost-files/dismiss')">{{T "app_info.lost_files_dismiss_btn"}}</button>
<span class="form-hint app-press-err" style="display:none;color:var(--red)"></span>
</div>
{{- end}}
{{if .Stack.Updating}}
<div class="alert alert-info" style="margin-top:1rem" data-update-phase="{{.Stack.UpdatePhase}}">{{updatePhaseText .Stack}}</div>
{{else if and .Stack.HoldReason (eq .Stack.HoldKind "unhealthy_stop")}}
<div class="alert alert-warning" style="margin-top:1rem" data-unhealthy-stop="true">{{holdText .Stack}}</div>
{{else if .Stack.HoldReason}}
<div class="alert alert-error" style="margin-top:1rem" data-held="true">{{holdText .Stack}}{{if not .Stack.HoldNoWholeCopy}} <a href="/backups/apps" class="btn btn-sm btn-outline">{{T "app_info.mentesek"}}</a>{{end}}{{if .HoldLogKept}}<br><span data-hold-log="true">{{T "app_info.hold_log_kept"}}</span> <a href="/apps/{{.Meta.Slug}}/logs" class="btn btn-sm btn-outline">{{T "app_info.naplo"}}</a>{{end}}</div>
{{else if .Stack.UpdateError}}
<div class="alert alert-warning" style="margin-top:1rem" data-update-error="true">{{updateErrorText .Stack}}</div>
{{else if .UpdateUndoneLine}}
<div class="alert alert-info" style="margin-top:1rem" data-update-undone="true">{{.UpdateUndoneLine}}</div>
{{else if .AutoUpdateLine}}
<div class="alert alert-info" style="margin-top:1rem" data-auto-update="done">{{.AutoUpdateLine}}</div>
{{end}}{{if and .Stack.HealthProbe .Stack.HealthProbe.NotChecked}}{{/* R-772: the declared health check could not run — said, never shown as healthy. */}}
<p class="form-hint" style="margin-top:0.5rem" data-health-not-checked="true">{{T "health.no_probe_container"}}</p>{{end}}
{{if .MissingStorageLabel}}
<div class="alert alert-warning" style="margin-top:1rem">
<strong><svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> {{T "app_info.hianyzo_tarhely"}} {{.MissingStorageLabel}}</strong><br>
{{T "app_info.ennek_az_alkalmazasnak_az_adattaroloja"}}
</div>
{{end}}
{{end}}
{{/* app_tab_bar — plain links, no JavaScript. An alert's link must land ON a tab, and the log
poller must not run on the other three; hidden panels give neither. */}}
{{define "app_tab_bar"}}
{{- if .AppTabs}}
<nav class="app-tabs" aria-label="{{T "app_page.tabs_label"}}">
{{- range .AppTabs}}
<a class="app-tab{{if .Active}} app-tab-active{{end}}" href="{{.Href}}" data-tab="{{.Key}}"
{{if .Active}}aria-current="page" aria-selected="true"{{else}}aria-selected="false"{{end}}>{{.Label}}</a>
{{- end}}
</nav>
{{- end}}
{{end}}
{{/* app_press_script — the shared POST-and-reload used by every card button on the page. It was the
details page's own script; the cards moved to tabs and it came with them. */}}
{{define "app_press_script"}}
<script>
function appPress(btn, url) {
var err = btn.parentNode.querySelector('.app-press-err');
if (err) { err.style.display = 'none'; }
btn.disabled = true;
fetch(url, {method: 'POST', headers: csrfHeaders(), credentials: 'same-origin'})
.then(function (r) { return r.json(); })
.then(function (j) {
if (!j.ok) { throw new Error(j.error || ''); }
window.location.reload();
})
.catch(function (e) { btn.disabled = false; if (err) { err.textContent = e.message; err.style.display = 'inline'; } });
}
// v0.315.0: one script for EVERY after-start field (Part 4). The env var is the only thing that
// varies, and the server decides what the value means — see after_start.go.
function appAfterStart(btn, slug, envVar) {
var box = btn.closest('.after-start-card'), err = box.querySelector('.app-press-err');
var input = box.querySelector('input');
err.style.display = 'none'; btn.disabled = true;
fetch('/apps/' + slug + '/after-start/' + envVar, {
method: 'POST',
headers: Object.assign({'Content-Type': 'application/json'}, csrfHeaders()),
credentials: 'same-origin',
body: JSON.stringify({value: (input ? input.value : '').trim()})
}).then(function (r) { return r.json(); })
.then(function (j) { if (!j.ok) { throw new Error(j.error || ''); } window.location.reload(); })
.catch(function (e) { btn.disabled = false; err.textContent = e.message; err.style.display = 'inline'; });
}
</script>
{{end}}
@@ -224,7 +224,7 @@
{{if .Tier2UserDisabled}}
<span class="layer-unconfigured">{{T "backups_apps.2_mentes_kikapcsolva"}}</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">{{T "backups_apps.beallitas"}}</a>
<a href="{{appPageURL .Slug}}/data" class="btn btn-xs btn-outline">{{T "backups_apps.beallitas"}}</a>
</div>
{{else if and .Tier2Configured .Tier2DestDisconnected}}
<span class="layer-method" style="opacity:.6">rsync</span>
@@ -239,7 +239,7 @@
{{end}}
<span class="tier-contents" style="opacity:.6">{{.Tier23Contents}}</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">{{T "backups_apps.beallitas"}}</a>
<a href="{{appPageURL .Slug}}/data" class="btn btn-xs btn-outline">{{T "backups_apps.beallitas"}}</a>
</div>
{{else if and .Tier2Configured .Tier2DestInactive}}
<span class="layer-method" style="opacity:.6">rsync</span>
@@ -254,7 +254,7 @@
{{end}}
<span class="tier-contents" style="opacity:.6">{{.Tier23Contents}}</span>
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">{{T "backups_apps.beallitas"}}</a>
<a href="{{appPageURL .Slug}}/data" class="btn btn-xs btn-outline">{{T "backups_apps.beallitas"}}</a>
</div>
{{else if .Tier2Configured}}
<span class="layer-method">rsync</span>
@@ -310,7 +310,7 @@
<button type="submit" class="btn btn-xs btn-danger-outline" data-confirm="{{.Tier2UnitConfirm}}">{{T "backups_apps.teljes_visszaallitas_a_masolatbol"}}</button>
</form>
{{end}}
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">{{T "backups_apps.beallitas"}}</a>
<a href="{{appPageURL .Slug}}/data" class="btn btn-xs btn-outline">{{T "backups_apps.beallitas"}}</a>
</div>
{{else}}
<span class="layer-auto-ok"><svg class="ico ico-sm"><use href="#i-check"/></svg> {{T "backups_apps.1_mentes_auto"}}</span>
@@ -319,7 +319,7 @@
<span class="layer-reason" style="opacity:.85" title="{{T "backups_apps.a_2_mentes_automatikus_kulon"}}">{{.Tier2LastError}}</span>
{{end}}
<div class="layer-actions">
<a href="/stacks/{{.StackName}}/backup" class="btn btn-xs btn-outline">{{T "backups_apps.beallitas"}}</a>
<a href="{{appPageURL .Slug}}/data" class="btn btn-xs btn-outline">{{T "backups_apps.beallitas"}}</a>
</div>
{{end}}
</div>
@@ -207,7 +207,7 @@
<button class="btn btn-sm btn-warning" onclick="stackAction(event, '{{.Name}}', 'restart')" title="{{T "common.ujrainditas"}}"><svg class="ico ico-sm"><use href="#i-rotate-cw"/></svg></button>
{{end}}
{{else if not .Deployed}}
<a href="/stacks/{{.Name}}/deploy" class="btn btn-sm btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">{{T "dashboard.telepites"}}</a>
<a href="{{appPageURL .Meta.Slug}}/install" class="btn btn-sm btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">{{T "dashboard.telepites"}}</a>
{{else}}
{{if isOperational .State}}
{{$subdomain := index $.Subdomains .Name}}
@@ -218,7 +218,7 @@
<button class="btn btn-sm btn-success" onclick="stackAction(event, '{{.Name}}', 'start')" title="{{T "dashboard.inditas"}}"><svg class="ico ico-sm"><use href="#i-play"/></svg></button>
{{if not .Orphaned}}<button class="btn btn-sm btn-danger" onclick="removeStack('{{.Name}}')">{{T "common.eltavolitas"}}</button>{{end}}
{{end}}
<a href="/stacks/{{.Name}}/logs" class="btn btn-sm btn-outline">{{T "dashboard.naplo"}}</a>
<a href="{{appPageURL .Meta.Slug}}/logs" class="btn btn-sm btn-outline">{{T "dashboard.naplo"}}</a>
{{if .Orphaned}}<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Name}}')">{{T "common.torles"}}</button>{{end}}
{{end}}
{{template "app_list_row_end"}}
+16 -669
View File
@@ -1,27 +1,25 @@
{{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="/stacks" class="btn btn-sm btn-outline">{{T "deploy.vissza"}}</a>
<h2>{{.Meta.DisplayName}} — {{if .AlreadyDeployed}}{{T "deploy.beallitasok"}}{{else}}{{T "deploy.telepites"}}{{end}}</h2>
<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">
{{if .AlreadyDeployed}}
{{if isOperational .Stack.State}}
<button class="btn btn-sm btn-warning" onclick="stackAction(event, '{{.Stack.Name}}', 'restart')">{{T "common.ujrainditas"}}</button>
<button class="btn btn-sm btn-danger" onclick="stackAction(event, '{{.Stack.Name}}', 'stop')">{{T "deploy.leallitas"}}</button>
{{else}}
<button class="btn btn-sm btn-success" onclick="stackAction(event, '{{.Stack.Name}}', 'start')">{{T "deploy.inditas"}}</button>
{{end}}
{{if and (isOperational .Stack.State) .EffectiveSubdomain}}
<a href="https://{{.EffectiveSubdomain}}.{{.Domain}}{{.Meta.OpenPath}}" target="_blank" class="btn btn-sm btn-outline">{{T "deploy.megnyitas"}}</a>
{{end}}
{{end}}
<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}}
@@ -34,7 +32,6 @@
{{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>{{T "common.pi_kompatibilis"}}</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>
@@ -44,395 +41,6 @@
</div>
</div>
{{if .AlreadyDeployed}}
<div class="alert alert-info">
{{T "deploy.ez_az_alkalmazas_mar_telepitve"}}
</div>
{{if .StorageInfo}}
<div class="deploy-storage-info">
<h4>{{T "deploy.adattarolas"}}</h4>
<div class="settings-grid">
<div class="settings-row">
<span class="settings-label">{{T "deploy.tarhely"}}</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">{{T "deploy.adatmeret"}}</span>
<span class="settings-value mono">{{numText .StorageInfo.DataSizeHuman}}</span>
</div>
{{end}}
{{if .StorageInfo.FreeHuman}}
<div class="settings-row">
<span class="settings-label">{{T "deploy.szabad_hely"}}</span>
<span class="settings-value mono">{{T "deploy.szabad"}}</span>
</div>
{{end}}
</div>
{{if .OtherStoragePaths}}
<span class="btn btn-sm btn-outline" style="margin-top:.75rem;opacity:.45;cursor:not-allowed" title="{{T "deploy.hamarosan"}}">
<svg class="ico ico-sm"><use href="#i-upload"/></svg> {{T "deploy.mozgatas_masik_tarolora"}}
</span>
{{end}}
</div>
{{end}}
{{if .StaleData}}
<div class="deploy-stale-data">
<h4>{{T "deploy.korabbi_adatok"}}</h4>
<p class="form-hint" style="margin-bottom:1rem">
{{T "deploy.az_alkalmazas_adatainak_masolata_megtala"}}
</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 "deploy.tarhely"}}</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">{{T "deploy.meret"}}</span>
<span class="settings-value mono">{{numText .SizeHuman}}</span>
</div>
<div class="settings-row">
<span class="settings-label">{{T "deploy.mappak"}}</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)">
{{T "deploy.korabbi_adatok_torlese"}}
</button>
</div>
{{end}}
</div>
{{end}}
{{end}}
{{if .AlreadyDeployed}}
<div class="deploy-cross-drive">
<h4>{{T "deploy.biztonsagi_mentes"}}</h4>
<div class="cross-drive-nightly">
<span class="form-hint" style="display:block;margin-top:.25rem">
{{T "deploy.az_alkalmazas_adatbazisa_es_docker"}}
</span>
</div>
</div>
{{end}}
{{if .HasIntegrations}}
<div class="app-optional-config">
<h3>{{T "deploy.integraciok"}}</h3>
<p class="config-group-desc">
{{T "deploy.mas_telepitett_alkalmazasokkal_valo_ossz"}}
</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>{{T "deploy.nincs_telepitve"}}</span>
{{else if not .TargetRunning}}
<span class="tag tag-neutral" style="margin-top:.25rem"><span class="dot"></span>{{T "deploy.celalkalmazas_leallitva"}}</span>
{{else if eq .Status "error"}}
<span class="tag tag-crit" style="margin-top:.25rem"><span class="dot"></span>{{T "common.hiba"}}</span>
{{else if .Enabled}}
<span class="tag tag-run" style="margin-top:.25rem"><span class="dot"></span>{{T "deploy.aktiv"}}</span>
{{end}}
</div>
<label class="toggle">
<input type="checkbox"
{{if .Enabled}}checked{{end}}
{{if not .TargetDeployed}}disabled title="{{T "deploy.a_celalkalmazas_nincs_telepitve"}}"{{end}}
{{if and .TargetDeployed (not .TargetRunning)}}disabled title="{{T "deploy.a_celalkalmazas_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 || '{{T "common.hiba_tortent"}}');
} else {
setTimeout(function(){ location.reload(); }, 500);
return;
}
} catch(err) {
checkbox.checked = !enable;
showAlert('{{T "deploy.halozati_hiba"}}');
}
checkbox.disabled = false;
}
</script>
{{end}}
{{if .GeoGlobalEnabled}}
<div class="app-optional-config">
<h3>{{T "deploy.foldrajzi_korlatozas"}}</h3>
<p class="config-group-desc">
{{T "deploy.az_alkalmazas_egyeni_orszagkorlatozas_ne"}}
</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">{{T "deploy.egyeni_orszagkorlatozas"}}</span>
</label>
<div id="app-geo-override-details" {{if not .GeoAppOverride}}style="display:none"{{end}}>
<div class="form-group">
<label>{{T "deploy.engedelyezett_orszagok_ehhez_az_alkalmaz"}}</label>
<div class="geo-country-selector">
<input type="text" id="app-geo-search" class="form-control config-input"
placeholder="{{T "deploy.orszag_keresese"}}" 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()">{{T "deploy.mentes"}}</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">{{T "deploy.nincs_talalat"}}</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 || '{{T "deploy.mentve"}}') : (d.error || '{{T "common.hiba"}}');
status.className = 'config-save-status ' + (d.ok ? 'config-save-ok' : 'config-save-err');
setTimeout(function(){ status.textContent=''; }, 5000);
})
.catch(function(){
status.textContent = '{{T "deploy.halozati_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>{{T "deploy.opcionalis_beallitasok"}}</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">{{T "deploy.regisztracios_utmutato"}}</a></p>{{end}}
{{if eq .Type "secret_input"}}
{{/* R-255 (v0.308.0): a stored secret is NOT in the page; it is fetched on demand like the
auto-generated ones (R-254). A blank field that was never revealed is not sent, so
saving other fields keeps it. */}}
{{$set := index $.CurrentValues .EnvVar}}
<div class="input-with-button">
<input type="password" id="opt-{{.EnvVar}}" name="{{.EnvVar}}" class="config-input"
value="" placeholder="{{if $set}}••••••••••••{{else}}{{.Label}}{{end}}"
{{if $set}}data-secret-set="1" {{end}}autocomplete="off">
{{if $set}}<button type="button" class="btn btn-sm btn-outline" onclick="revealAutoField('{{$.Stack.Name}}','{{.EnvVar}}', this, 'opt-{{.EnvVar}}')">{{T "deploy.megjelenites"}}</button>{{end}}
</div>
{{else}}
<input type="text"
id="opt-{{.EnvVar}}"
name="{{.EnvVar}}"
class="config-input"
value="{{index $.CurrentValues .EnvVar}}"
placeholder="{{.Label}}"
autocomplete="off">
{{end}}
</div>
{{end}}
</div>
</div>
{{end}}
<div class="config-actions">
<button class="btn btn-primary" id="save-optional-config" onclick="saveOptionalConfig('{{.Stack.Name}}')">
{{T "deploy.mentes"}}
</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) {
// R-255: a stored secret the household never revealed and left blank stays as it is.
if (input.dataset.secretSet === '1' && input.dataset.revealed !== '1' && input.value === '') return;
values[input.name] = input.value;
});
btn.disabled = true;
btn.textContent = '{{T "deploy.mentes_2"}}';
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 || '{{T "deploy.mentve"}}');
status.className = 'config-save-status config-save-ok';
} else {
status.textContent = (data.error || '{{T "common.hiba"}}');
status.className = 'config-save-status config-save-err';
}
} catch(err) {
status.textContent = '{{T "deploy.halozati_hiba"}}';
status.className = 'config-save-status config-save-err';
}
btn.disabled = false;
btn.textContent = '{{T "deploy.mentes"}}';
setTimeout(function() { status.textContent = ''; }, 5000);
}
</script>
{{end}}
{{if .AppEmailSupported}}
<div class="app-optional-config">
<h3>{{T "deploy.email_kuldes"}}</h3>
<p class="config-group-desc">
{{T "deploy.ez_az_alkalmazas_tud_emailt"}}
</p>
{{if not .AppEmailGlobalOn}}
<p class="alert alert-warning" style="margin-bottom:1rem">
{{T "deploy.az_alkalmazas_email_jelenleg_ki"}}
</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}}>
{{T "deploy.email_kuldes_engedelyezese_ennel_az"}}
</label>
<div class="config-actions">
<button class="btn btn-primary" type="submit">{{T "deploy.mentes"}}</button>
<span class="config-group-desc" style="margin-left:.5rem">{{T "deploy.a_mentes_ujrainditja_az_alkalmazast"}}</span>
</div>
</form>
</div>
{{end}}
{{if and (not .AlreadyDeployed) .MemoryInfo}}
{{with .MemoryInfo}}
{{if .Available}}
@@ -497,177 +105,13 @@
<svg class="ico ico-sm"><use href="#i-triangle-alert"/></svg> {{T "deploy.nincs_eleg_szabad_tarhely_a"}}
</div>
{{end}}
{{if .AutoFields}}
<div class="form-section">
<h4>{{T "deploy.automatikusan_generalt_ertekek"}}</h4>
{{if .AlreadyDeployed}}
<p class="form-section-desc">{{T "deploy.ezek_az_ertekek_automatikusan_jottek"}}</p>
{{else}}
<p class="form-section-desc">{{T "deploy.ezek_az_ertekek_a_telepitessel"}}</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> {{T "deploy.automatikusan_generalva"}}</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)">{{T "deploy.megjelenites"}}</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)">{{T "deploy.megjelenites"}}</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>{{T "deploy.beallitasok"}}</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">{{T "deploy.telepites_utan_nem_modosithato"}}</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="{{T "deploy.aldomain_placeholder"}}"
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">{{T "deploy.az_aldomain_telepites_utan_nem"}}</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">
{{- if $.AlreadyDeployed}}
{{- /* R-709 (v0.280.0): an installed app's password is never in this page; the eye fetches it. */}}
<input type="password" id="field-{{.EnvVar}}" class="form-control" value="" placeholder="••••••••••••" disabled>
{{- if not (and $.RestoredLogins (index $.RestoredLogins .EnvVar))}}
<button type="button" class="btn btn-sm btn-outline pw-toggle-btn"
onclick="revealPasswordField('{{$.Stack.Name}}', '{{.EnvVar}}', this)"
title="{{T "deploy.megjelenites"}}">&#128065;</button>
{{- end}}
{{- else}}
<input type="password" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}"
placeholder="{{.Placeholder}}"
data-field-type="password"
required>
<button type="button" class="btn btn-sm btn-outline pw-toggle-btn"
onclick="togglePasswordField('field-{{.EnvVar}}', 'field-confirm-{{.EnvVar}}', this)"
title="{{T "deploy.megjelenites"}}">&#128065;</button>
<button type="button" class="btn btn-sm btn-outline"
onclick="generatePassword('field-{{.EnvVar}}', 'field-confirm-{{.EnvVar}}', '{{.Generate}}')">{{T "deploy.generalas"}}</button>
{{- end}}
</div>
{{if $.AlreadyDeployed}}
<span class="form-hint">{{if and $.RestoredLogins (index $.RestoredLogins .EnvVar)}}{{T "deploy.login_from_backup"}}{{else}}{{T "deploy.telepiteskor_beallitott_kezdeti_jelszo_h"}}{{end}}</span>
{{else}}
<div class="input-with-button" style="margin-top:.25rem">
<input type="password" id="field-confirm-{{.EnvVar}}"
class="form-control" placeholder="{{T "deploy.jelszo_megerositese"}}"
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">{{T "deploy.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}}>
{{T "deploy.szabad_2"}}{{if .NotAllowed}} ({{.NotAllowedNote}}){{else if .IsDefault}} {{T "deploy.alapertelmezett"}}{{end}}
</option>
{{end}}
{{if $.CurrentHDDPathMissing}}
<option value="{{$.CurrentHDDPath}}" selected disabled>{{T "deploy.nem_elerheto"}}</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> {{T "deploy.a_kivalasztott_tarhely_majdnem_megtelt"}}
</div>
<div class="form-hint" style="margin-top:.4rem;opacity:.8">
<svg class="ico ico-sm"><use href="#i-info"/></svg> {{T "deploy.a_kivalasztott_meghajton_az_alkalmazas"}}
</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)">{{T "deploy.nincs_regisztralt_adattarolo_adja_meg"}}</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}}
{{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="/stacks" class="btn btn-outline">{{T "common.megsem"}}</a>
<a href="/apps/{{.Meta.Slug}}" class="btn btn-outline" data-install-cancel="1">{{T "common.megsem"}}</a>
</div>
{{end}}
</form>
@@ -808,7 +252,7 @@ function buildPostDeployCard(stackName) {
// 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">{{T "deploy.beallitasok_es_jelszavak_megtekintese"}}</a>' +
'<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>';
@@ -831,56 +275,6 @@ document.addEventListener('DOMContentLoaded', function() {
// 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 revealPasswordField(stackName, envVar, btn) {
var el = document.getElementById('field-' + envVar);
if (!el) return;
if (btn.dataset.shown === '1') {
el.value = '';
el.type = 'password';
btn.dataset.shown = '';
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 || '{{T "deploy.a_lekeres_nem_sikerult"}}'); return; }
el.value = j.data.value;
el.type = 'text';
el.dataset.revealed = '1';
btn.dataset.shown = '1';
}).catch(function () { btn.disabled = false; showAlert('{{T "deploy.a_lekeres_nem_sikerult"}}'); });
}
function revealAutoField(stackName, envVar, btn, elId) {
var el = document.getElementById(elId || ('auto-field-' + envVar));
if (!el) return;
if (btn.dataset.shown === '1') {
el.value = '';
el.type = 'password';
el.dataset.revealed = '';
btn.dataset.shown = '';
btn.textContent = '{{T "deploy.megjelenites"}}';
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 || '{{T "deploy.a_lekeres_nem_sikerult"}}'); return; }
el.value = j.data.value;
el.type = 'text';
btn.dataset.shown = '1';
btn.textContent = '{{T "deploy.elrejtes"}}';
}).catch(function () { btn.disabled = false; showAlert('{{T "deploy.a_lekeres_nem_sikerult"}}'); });
}
function toggleAutoField(fieldId, btn) {
var el = document.getElementById(fieldId);
if (!el) return;
@@ -947,53 +341,6 @@ function copyPostDeployCred(btn, val) {
}
}
function deleteStaleData(stackName, stalePath, btn) {
// Two-step inline confirm (the irreversible delete keeps its double acknowledgement).
felhomConfirm(btn, '{{T "deploy.biztosan_torolni_szeretned_a_korabbi"}} ' + stalePath + ' {{T "deploy.figyelem_a_muvelet_visszavonhatatlan_elo"}}', function () {
felhomConfirm(btn, '{{T "deploy.utolso_figyelmeztetes_a_torles_visszavon"}}', function () {
deleteStaleDataRun(stackName, stalePath, btn);
});
});
}
function deleteStaleDataRun(stackName, stalePath, btn) {
btn.disabled = true;
btn.textContent = '{{T "deploy.torles_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('{{T "common.hiba_kettospont"}} ' + (data.error || '{{T "deploy.ismeretlen_hiba"}}'));
btn.disabled = false;
btn.textContent = '{{T "deploy.korabbi_adatok_torlese"}}';
return;
}
var msg = '{{T "deploy.korabbi_adatok_torolve_n_nfelszabaditott"}} ' + (data.freed_human || '?');
if (data.errors && data.errors.length > 0) {
msg += '{{T "deploy.n_nnehany_hiba_tortent_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('{{T "deploy.halozati_hiba_2"}} ' + e.message);
btn.disabled = false;
btn.textContent = '{{T "deploy.korabbi_adatok_torlese"}}';
});
}
document.getElementById('deploy-form').addEventListener('submit', async function(e) {
e.preventDefault();
@@ -1143,7 +490,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function
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="/stacks/' + stackName + '/logs" class="btn btn-outline" style="margin-top:.75rem">{{T "deploy.naplok_megtekintese"}}</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';
} else if (state === 'deploying') {
@@ -1178,7 +525,7 @@ document.getElementById('deploy-form').addEventListener('submit', async function
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="/stacks/' + stackName + '/logs" class="btn btn-outline" style="margin-top:.75rem">{{T "deploy.naplok_megtekintese"}}</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';
}
@@ -56,7 +56,6 @@
<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>{{T "common.pi_kompatibilis"}}</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}}
{{if and .Deployed (index $.StorageLabels .Name)}}<span class="metarow" title="{{T "stacks.adattarolo"}}"><svg class="ico"><use href="#i-hard-drive"/></svg>{{index $.StorageLabels .Name}}</span>{{end}}
@@ -85,7 +84,7 @@
{{else if not .Deployed}}
{{/* The endpoint refuses a non-installable template server-side; hiding the button
here keeps the two consistent rather than offering an action that will fail. */}}
{{if .InstallInterrupted}}<div class="alert alert-warning" data-install-interrupted="true">{{T "stacks.install_interrupted"}}</div>{{end}}{{if canInstall .Meta}}<a href="/stacks/{{.Name}}/deploy" class="btn btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">{{T "stacks.telepites"}}</a>{{end}}
{{if .InstallInterrupted}}<div class="alert alert-warning" data-install-interrupted="true">{{T "stacks.install_interrupted"}}</div>{{end}}{{if canInstall .Meta}}<a href="{{appPageURL .Meta.Slug}}/install" class="btn btn-primary" onclick="return checkBeforeDeploy(event, '{{.Name}}')">{{T "stacks.telepites"}}</a>{{end}}
<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">{{T "stacks.reszletek"}}</a>
{{else}}
{{/* Slice 4 (v0.238.0): the UPDATING and HELD checks come BEFORE isOperational. That
@@ -110,7 +109,7 @@
<button class="btn btn-success" onclick="stackAction(event, '{{.Name}}', 'start')">{{T "stacks.inditas"}}</button>
{{if not .Orphaned}}<button class="btn btn-danger" onclick="removeStack('{{.Name}}')">{{T "common.eltavolitas"}}</button>{{end}}
{{end}}
<a href="/stacks/{{.Name}}/logs" class="btn btn-outline">{{T "stacks.naplok"}}</a>
<a href="{{appPageURL .Meta.Slug}}/logs" class="btn btn-outline">{{T "stacks.naplok"}}</a>
{{if not .Orphaned}}<a href="{{appPageURL .Meta.Slug}}" class="btn btn-outline">{{T "stacks.reszletek"}}</a>{{end}}
{{if .Orphaned}}<button class="btn btn-danger" onclick="deleteOrphanStack('{{.Name}}')">{{T "common.torles"}}</button>{{end}}
{{end}}
@@ -1,99 +0,0 @@
{{define "tier2_config"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>{{T "tier2_config.2_mentes_beallitasa"}}</h2>
<a href="/backups/apps" class="btn btn-outline btn-sm">{{T "tier2_config.vissza_a_mentesekhez"}}</a>
</div>
{{if .Flash}}<div class="monitoring-banner monitoring-banner-green">{{.Flash}}</div>{{end}}
{{if .FlashError}}<div class="monitoring-banner monitoring-banner-red">{{.FlashError}}</div>{{end}}
<div class="monitor-card">
{{with .Tier2}}
<p style="color:var(--text-3);font-size:.9rem;margin-top:0">
{{T "tier2_config.a_2_mentes_egy_masik"}}
</p>
{{if not .IsHDDApp}}
{{if eq $.SystemBackup "protected"}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="protected">
{{T "tier2_config.rendszerlemez.vedett"}}
</div>
{{else if eq $.SystemBackup "same_disk"}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="same_disk">
{{T "tier2_config.rendszerlemez.ugyanaz"}}
</div>
{{else if eq $.SystemBackup "absent"}}
<div class="monitoring-banner monitoring-banner-red" style="margin-top:1rem" data-system-backup="absent">
{{T "tier2_config.rendszerlemez.hianyzik"}}
</div>
{{else}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem" data-system-backup="unknown">
{{T "tier2_config.rendszerlemez.ismeretlen"}}
</div>
{{end}}
{{else}}
<h3 style="margin-top:1.5rem">{{T "tier2_config.jelenlegi_allapot"}}</h3>
<div class="sysinfo-grid">
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.2_mentes"}}</span>
<span class="sysinfo-value">{{if .Disabled}}{{T "tier2_config.kikapcsolva"}}{{else}}{{T "tier2_config.bekapcsolva"}}{{end}}</span>
</div>
{{if .NoTarget}}
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.cel"}}</span>
<span class="sysinfo-value text-error">{{T "tier2_config.nincs_elerheto_off_drive_cel"}}</span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.megjegyzes"}}</span>
<span class="sysinfo-value" style="color:var(--text-3)">{{.NoTargetReason}}</span>
</div>
{{else}}
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.cel_meghajto"}}</span>
<span class="sysinfo-value">{{.EffectiveLabel}}{{if .EffectiveIsSSD}} {{T "tier2_config.csak_db_konfiguracio"}}{{end}}</span>
</div>
<div class="sysinfo-row">
<span class="sysinfo-label">{{T "tier2_config.kivalasztas_modja"}}</span>
<span class="sysinfo-value" style="color:var(--text-3)">{{if .Preferred}}{{T "tier2_config.kezi_valasztas"}}{{else}}{{T "tier2_config.automatikus_kisbetu"}}{{end}} — {{.EffectiveDesc}}</span>
</div>
{{end}}
</div>
{{if .EffectiveIsSSD}}
<div class="monitoring-banner monitoring-banner-yellow" style="margin-top:1rem">
{{T "tier2_config.jelenleg_csak_a_belso_ssd"}}
</div>
{{end}}
<form method="POST" action="/stacks/{{$.StackName}}/backup" style="margin-top:1.5rem">
{{$.CSRFField}}
<div class="form-group">
<label style="display:flex;align-items:center;gap:.5rem">
<input type="checkbox" name="enabled" value="on" {{if not .Disabled}}checked{{end}}>
{{T "tier2_config.2_mentes_bekapcsolva"}}
</label>
</div>
<div class="form-group">
<label for="target">{{T "tier2_config.cel_meghajto"}}</label>
<select id="target" name="target" class="form-control">
<option value="">{{T "tier2_config.automatikus"}}{{if not .NoTarget}} {{T "tier2_config.jelenleg"}}{{end}}</option>
{{range .Alternatives}}
<option value="{{.Path}}" {{if eq .Path $.Tier2.Preferred}}selected{{end}}>{{.Label}}</option>
{{end}}
</select>
{{if not .Alternatives}}
<div style="font-size:.8rem;color:var(--text-3);margin-top:.4rem">
{{T "tier2_config.nincs_masik_adatmeghajto_automatikus_cel"}}
</div>
{{end}}
</div>
<button type="submit" class="btn btn-primary">{{T "tier2_config.mentes"}}</button>
</form>
{{end}}
{{end}}
</div>
{{template "layout_end" .}}
{{end}}