6d90374f63
gates / gates (push) Successful in 45s
internal/privpolicy checks the RENDERED compose (privileged, cap_add, devices, host namespaces, security_opt,
sysctls, volumes_from, build/include/extends, env_file/config files outside the stack dir, aliasing named volumes,
binds outside the box's data roots, the Docker socket, propagation, ${VAR} in a privileged key) against an
allow-list compiled into the controller (one entry: homepage's read-only socket). Asked before every compose
write: the 15-minute sync, the guarded update/ladder step, the install, both settings changes, both restores
(before the stop). A refusal writes nothing, keeps the app on its last good compose, raises the operator-only
event app_definition_refused once per definition and puts one line on the app page. The sync never copies a
template named like a controller infra stack. Structural guard TestAllowList_IsWiredIntoEveryComposeWriter.
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
150 lines
9.5 KiB
HTML
150 lines
9.5 KiB
HTML
{{/* 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"}}
|
|
<a href="/stacks" class="app-back">{{T "app_info.alkalmazasok"}}</a>
|
|
|
|
<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">
|
|
<h2 class="app-hero-name">{{.Meta.DisplayName}}</h2>
|
|
{{if .InfraInfo}}<p class="app-hero-tagline" data-infra-app="1">{{.InfraInfo.Description}}</p>{{else if .AppInfo.Tagline}}<p class="app-hero-tagline">{{.AppInfo.Tagline}}</p>{{else if .Meta.Description}}<p class="app-hero-tagline">{{.Meta.Description}}</p>{{end}}
|
|
<div class="app-chips">
|
|
{{if .Deployed}}
|
|
<span class="app-chip" data-state-chip="{{.Stack.State}}">● {{stateLabel .Stack.State}}</span>
|
|
{{if .Stack.Orphaned}}<span class="app-chip">{{T "common.elavult"}}</span>{{end}}
|
|
{{template "meta_badge" (lifecycleBadge .Meta)}}
|
|
{{template "meta_badge" (updateBadge .Stack)}}{{if and (gt .Stack.LadderStepsLeft 0) (not .Stack.HoldReason)}}<span class="app-meta" data-ladder-steps="{{.Stack.LadderStepsLeft}}">{{T "app_info.ladder_steps_left"}} {{.Stack.LadderStepsLeft}}</span>{{end}}
|
|
{{/* v0.316.0: the auto-update SUCCESS line is meta text here, not a banner below.
|
|
A success that asked nothing of the household is a date, not news; the banners
|
|
under the hero stay for the things that DO need them (a failed update, a hold,
|
|
lost files, a missing drive) — R-571's placement rule is untouched. */}}
|
|
{{if .EffectiveSubdomain}}<span class="app-meta" data-app-address="1">{{.EffectiveSubdomain}}.{{.Domain}}</span>{{end}}
|
|
{{if .AutoUpdateLine}}<span class="app-meta" data-auto-update="done">{{.AutoUpdateLine}}</span>{{end}}
|
|
{{else}}
|
|
<span class="app-chip">~{{.Meta.Resources.MemRequest}} RAM</span>
|
|
{{if .Meta.Category}}<span class="app-chip">{{.Meta.Category}}</span>{{end}}
|
|
{{if .Meta.Resources.NeedsHDD}}<span class="app-chip">{{T "common.hdd_szukseges"}}</span>{{end}}
|
|
{{if .Meta.Resources.HungarianUI}}<span class="app-chip">{{T "common.magyar_felulet"}}</span>{{end}}
|
|
{{end}}
|
|
</div>
|
|
</div>
|
|
{{if and .Deployed (or (and .AppOperational .EffectiveSubdomain) .FilesLink)}}
|
|
<div class="app-hero-actions">
|
|
{{if and .AppOperational .EffectiveSubdomain}}<a href="https://{{.EffectiveSubdomain}}.{{.Domain}}{{.Meta.OpenPath}}" target="_blank" class="btn btn-primary" data-app-open="1">{{T "app_info.megnyitas"}}</a>{{end}}
|
|
{{/* v0.320.0: one of the app's folders in FileBrowser (datapath_card.go filesButtonLink). FileBrowser may
|
|
ask for its own login first; the button never promises one click. */}}
|
|
{{if .FilesLink}}<a href="{{.FilesLink}}" target="_blank" rel="noopener" class="btn btn-outline" data-app-files="1">{{T "app_page.files_btn"}}</a>{{end}}
|
|
</div>
|
|
{{end}}
|
|
{{if and .Deployed .Stack.Orphaned}}
|
|
<div class="app-hero-actions">
|
|
<button class="btn btn-sm btn-danger" onclick="deleteOrphanStack('{{.Stack.Name}}')">{{T "common.torles"}}</button>
|
|
</div>
|
|
{{end}}
|
|
</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>
|
|
{{end}}{{if .Stack.DefinitionRefused}}{{/* R-971: the box refused the catalog's newer definition; the app runs on its last good one. */}}
|
|
<div class="alert alert-warning" style="margin-top:1rem" data-definition-refused="true">{{T "app_info.definition_refused"}}</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}}
|