Indítópult launcher page + universal app placeholder icon (v0.163.0)

New /launcher page: a grid of large tappable tiles, one per openable deployed
app (subdomain presence is the single openability criterion, shared with the
Megnyitás button via the extracted Server.subdomainMap helper). Colored tiles
(deterministic slug color or .felhom.yml brand_color), white glyph/monogram
fallback, target=_blank links for operational apps, greyed unclickable tiles for
stopped ones. First sidebar item; / stays the Vezérlőpult.

Universal app placeholder: new AppPlaceholderSVG served at
/static/app-placeholder.svg, now the default FallbackIcon on app_list_row so a
logo-less app shows a placeholder instead of visibility:hidden. Brand mark is
never an app placeholder.

New Metadata.BrandColor; new funcmap tileColor/initial. 10 new test functions +
4 red-proofs. No agent coupling; MinAgent unchanged.
This commit is contained in:
2026-07-24 09:16:28 +02:00
parent a71cc58327
commit 987e915bf2
15 changed files with 632 additions and 26 deletions
@@ -9,13 +9,15 @@
dict keys: Slug (icon lookup), Name, Secondary (optional one-liner),
RowClass (optional extra row class, e.g. stack-state-run), Href (optional data-href),
FallbackIcon (optional last-resort icon URL — infra stacks pass the generic infra SVG).
FallbackIcon (optional last-resort icon URL). DEFAULTS to /static/app-placeholder.svg (the
generic app-grid glyph) when a caller omits it — so a logo-less app shows a placeholder, not a
hidden dead-end. Infra rows pass /static/infra-logo.svg to override that with the server glyph.
Do NOT hand-roll app rows — scripts/app_row_dedup_gate.py asserts this markup exists
here ONCE (the backups_apps expander header is the single allowlisted aligned copy). */}}
{{define "app_list_row"}}
<div class="app-row{{with .RowClass}} {{.}}{{end}}"{{with .Href}} data-href="{{.}}"{{end}}>
<img class="app-row-icon" src="{{logoURL .Slug}}" alt=""{{with .FallbackIcon}} data-fallback="{{.}}"{{end}}
<img class="app-row-icon" src="{{logoURL .Slug}}" alt="" data-fallback="{{if .FallbackIcon}}{{.FallbackIcon}}{{else}}/static/app-placeholder.svg{{end}}"
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .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-row-text">
<span class="app-row-name">{{.Name}}</span>
@@ -14,6 +14,7 @@
<symbol id="i-server" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="20" height="8" x="2" y="2" rx="2" ry="2" /> <rect width="20" height="8" x="2" y="14" rx="2" ry="2" /> <line x1="6" x2="6.01" y1="6" y2="6" /> <line x1="6" x2="6.01" y1="18" y2="18" /></symbol>
<symbol id="i-share" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="18" cy="5" r="3" /> <circle cx="6" cy="12" r="3" /> <circle cx="18" cy="19" r="3" /> <line x1="8.59" x2="15.42" y1="13.51" y2="17.49" /> <line x1="15.41" x2="8.59" y1="6.51" y2="10.49" /></symbol>
<symbol id="i-layout-grid" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="7" height="7" x="3" y="3" rx="1" /> <rect width="7" height="7" x="14" y="3" rx="1" /> <rect width="7" height="7" x="14" y="14" rx="1" /> <rect width="7" height="7" x="3" y="14" rx="1" /></symbol>
<symbol id="i-rocket" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91 0z" /> <path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z" /> <path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0" /> <path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5" /></symbol>
<symbol id="i-settings" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9.671 4.136a2.34 2.34 0 0 1 4.659 0 2.34 2.34 0 0 0 3.319 1.915 2.34 2.34 0 0 1 2.33 4.033 2.34 2.34 0 0 0 0 3.831 2.34 2.34 0 0 1-2.33 4.033 2.34 2.34 0 0 0-3.319 1.915 2.34 2.34 0 0 1-4.659 0 2.34 2.34 0 0 0-3.32-1.915 2.34 2.34 0 0 1-2.33-4.033 2.34 2.34 0 0 0 0-3.831A2.34 2.34 0 0 1 6.35 6.051a2.34 2.34 0 0 0 3.319-1.915" /> <circle cx="12" cy="12" r="3" /></symbol>
<symbol id="i-bell" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.268 21a2 2 0 0 0 3.464 0" /> <path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326" /></symbol>
<symbol id="i-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18" /> <path d="m6 6 12 12" /></symbol>
@@ -0,0 +1,43 @@
{{define "launcher"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>Indítópult</h2>
<span class="domain-badge">{{.Domain}}</span>
</div>
{{if .Apps}}
<div class="launch-grid">
{{range .Apps}}
{{if isOperational .State}}
<a class="launch-cell" href="https://{{.Subdomain}}.{{$.Domain}}{{.OpenPath}}" target="_blank" rel="noopener">
<span class="launch-tile" style="background: {{tileColor .Slug .BrandColor}}">
<span class="launch-mono">{{initial .DisplayName}}</span>
<img class="launch-logo" src="{{logoURL .Slug}}" alt=""
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else{this.onerror=null;this.style.display='none';}">
</span>
<span class="launch-name">{{.DisplayName}}</span>
{{if ne (stateStr .State) "running"}}<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>{{end}}
</a>
{{else}}
<div class="launch-cell launch-cell--off">
<span class="launch-tile launch-tile--off" style="background: {{tileColor .Slug .BrandColor}}">
<span class="launch-mono">{{initial .DisplayName}}</span>
<img class="launch-logo" src="{{logoURL .Slug}}" alt=""
onerror="if(!this.dataset.step){this.dataset.step='1';this.src='{{logoPNGURL .Slug}}';}else{this.onerror=null;this.style.display='none';}">
</span>
<span class="launch-name">{{.DisplayName}}</span>
<span class="tag tag-{{stateColor .State}}"><span class="dot"></span>{{stateLabel .State}}</span>
</div>
{{end}}
{{end}}
</div>
{{else}}
<div class="empty-state">
<p>Még nincs telepített alkalmazás.</p>
<p><a href="/stacks" class="empty-state-link">Alkalmazások telepítése</a></p>
</div>
{{end}}
{{template "layout_end"}}
{{end}}
@@ -68,6 +68,7 @@
<span class="customer-name">{{.CustomerName}}</span>
</div>
<ul class="nav-links">
<li><a href="/launcher" class="{{if eq .Page "launcher"}}active{{end}}"><svg class="ico"><use href="#i-rocket"/></svg>Indítópult</a></li>
<li><a href="/" class="{{if eq .Page "dashboard"}}active{{end}}"><svg class="ico"><use href="#i-layout-grid"/></svg>Vezérlőpult</a></li>
<li><a href="/stacks" class="{{if eq .Page "stacks"}}active{{end}}"><svg class="ico"><use href="#i-cloud"/></svg>Alkalmazások</a></li>
{{$storageOpen := or (eq .Page "storage") (eq .Page "storage-network")}}
@@ -1111,6 +1111,68 @@ a.stat-card:hover {
[data-href] { cursor: pointer; }
.empty-state { text-align: center; padding: 3rem; color: var(--text-3); }
.empty-state-link { color: var(--blue-bright); }
/* Indítópult (launcher) — a grid of large tappable app tiles. Each tile has a deterministic
per-app background (tileColor / brand_color override), the white monogram fallback UNDER the
logo (revealed when the logo fails to load), and the app name below. */
.launch-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
gap: 1.25rem;
margin-top: .5rem;
}
.launch-cell {
display: flex;
flex-direction: column;
align-items: center;
gap: .5rem;
text-decoration: none;
color: var(--text-1);
}
.launch-tile {
position: relative;
width: 100%;
aspect-ratio: 1 / 1;
border-radius: var(--radius);
border: 1px solid var(--line);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
transition: transform .12s ease, border-color .12s ease;
}
.launch-cell:hover .launch-tile {
transform: translateY(-2px);
border-color: var(--blue);
}
.launch-mono {
position: absolute;
color: #fff;
font-family: var(--font-ui);
font-size: 2.4rem;
font-weight: 600;
line-height: 1;
user-select: none;
}
.launch-logo {
position: relative;
width: 58%;
height: 58%;
object-fit: contain;
}
.launch-name {
max-width: 100%;
font-size: .85rem;
color: var(--text-1);
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.launch-cell--off { cursor: default; }
.launch-tile--off { opacity: .4; }
.launch-cell--off .launch-name { color: var(--text-3); }
/* Login page */
.login-body {