Files
felhom-controller/controller/internal/web/templates/app_info.html
T
admin 5a3437669f
gates / gates (push) Successful in 27s
v0.284.0 — a box deletes old app images (decision 53, R-736); an after_install app is held until its known login is replaced (R-741)
Image retention: after a done/undone guarded Update and at remove, an app's images older than its running
and previous one are deleted — never an image any container, installed compose or installed/previous record
names (box-wide keep set read at delete time); exact id, never forced or pruned; paused while any update runs;
a one-time sweep of catalog app images at the first start. Install hold: an after_install app is installed
behind the setup gate's door and opens when after_install succeeds or the household says it changed the login.
Tests TestImageRetention_* and TestInstallHold_* with red-proofs; parity fixture for the held card.

MinAgent: 0.131.0 (unchanged).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-09-30 22:29:33 +02:00

365 lines
19 KiB
HTML

{{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 .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}}</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 .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 .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>
<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 .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 (Number(b||0)/1e9).toFixed(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)}}
<script>
// v0.280.0: the household's presses on this page — "Done, I set it up" (decision 46), "I changed it" (R-710).
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}}