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:
@@ -1,5 +1,42 @@
|
||||
## Changelog
|
||||
|
||||
### v0.163.0 — Indítópult (app launcher page) + universal app placeholder icon (2026-07-24)
|
||||
|
||||
No agent coupling; MinAgent unchanged. Adds a customer-facing **Indítópult** launcher grid and a
|
||||
generic fallback icon for logo-less apps on every list surface.
|
||||
|
||||
**Indítópult (`/launcher`, new FIRST sidebar item, above Vezérlőpult):**
|
||||
- A grid of large tappable tiles, one per openable deployed app. The rule is intentionally the same
|
||||
one the „Megnyitás" button already uses: a tile exists **⟺** the stack has a subdomain (env
|
||||
`SUBDOMAIN` > `.felhom.yml` subdomain > `protectedStackSubdomains`). The controller's own stack is
|
||||
excluded by name. `/` still lands on the Vezérlőpult — the launcher is an ADDITIONAL page.
|
||||
- Tiles are colored rounded squares: a deterministic per-app color (FNV-1a of the slug → HSL hue,
|
||||
fixed S/L tuned for the dark theme), overridable with an optional `.felhom.yml` `brand_color`
|
||||
(`#rgb`/`#rrggbb`; an invalid value silently falls back to the slug color). The existing white
|
||||
monochrome logo renders on top; a logo-less app reveals the **monogram** initial underneath
|
||||
(multibyte-safe — „Óra" → „Ó").
|
||||
- Operational apps are a real `<a target="_blank" rel="noopener">` to the public URL (with
|
||||
`open_path`); stopped/exited/degraded apps render a **greyed, unclickable** tile with the honest
|
||||
Hungarian state badge — never a dead link. Empty state: „Még nincs telepített alkalmazás." + a link
|
||||
to `/stacks`.
|
||||
- New template funcs `tileColor` (returns a `template.CSS` — validated/computed in Go, because the
|
||||
html/template CSS filter mangles a legitimate `hsl()` from a func pipeline) and `initial`.
|
||||
|
||||
**Universal app placeholder icon:**
|
||||
- New embedded `AppPlaceholderSVG` (2×2 rounded-square app-grid glyph), served at
|
||||
`/static/app-placeholder.svg`. The canonical `app_list_row` now DEFAULTS its fallback to it, so a
|
||||
catalog app with a missing logo shows a generic placeholder on every list surface instead of the
|
||||
old `visibility:hidden` dead-end. Infra rows still override with `/static/infra-logo.svg`.
|
||||
- Design ruling: the felhom brand mark is NEVER an app placeholder (brand = platform identity only).
|
||||
|
||||
**Refactor (in-scope, single reason):** the subdomain-map assembly that lived inline twice
|
||||
(dashboard + Alkalmazások) is extracted to `Server.subdomainMap`; both call sites plus the launcher
|
||||
now share it (byte-for-byte priority unchanged).
|
||||
|
||||
**Metadata:** `stacks.Metadata` gains `BrandColor` (`brand_color`, omitempty). No catalog app sets it
|
||||
yet (curation is a parked follow-up).
|
||||
|
||||
|
||||
### v0.162.0 — R-71(a): the apply-bridge waits for the dust to settle (settle-gate) (2026-07-24)
|
||||
|
||||
No agent coupling; MinAgent unchanged. Origin:
|
||||
|
||||
+25
-1
@@ -7,7 +7,31 @@
|
||||
>
|
||||
> Ask Claude Code: "Please update CONTEXT.md with what we did today"
|
||||
|
||||
Last updated: 2026-07-24 (v0.162.0 — R-71a: the apply-bridge settle-gate)
|
||||
Last updated: 2026-07-24 (v0.163.0 — Indítópult launcher + universal app placeholder)
|
||||
|
||||
> **2026-07-24 — v0.163.0 (Indítópult app launcher + universal placeholder icon).** New
|
||||
> customer-facing `/launcher` page: the FIRST sidebar item (above Vezérlőpult), a grid of large
|
||||
> tappable tiles for openable deployed apps. `/` stays the Vezérlőpult — the launcher is ADDITIVE.
|
||||
> Design rulings recorded here:
|
||||
> - **(a) The felhom brand mark is NEVER an app placeholder** — brand = platform identity only. The
|
||||
> logo-less fallback everywhere is the new generic `AppPlaceholderSVG` (a 2×2 app-grid glyph,
|
||||
> `/static/app-placeholder.svg`), now the DEFAULT `FallbackIcon` on `app_list_row` (was
|
||||
> `visibility:hidden`). On the launcher tile the fallback is the **monogram**, not the placeholder.
|
||||
> - **(b) A launcher tile exists ⟺ a „Megnyitás" button would** — subdomain presence (env `SUBDOMAIN`
|
||||
> > `.felhom.yml` subdomain > `protectedStackSubdomains`) is the single openability criterion. The
|
||||
> controller stack is excluded by name. The subdomain assembly was extracted to
|
||||
> `Server.subdomainMap` (3 callers: dashboard, Alkalmazások, launcher; priority byte-unchanged).
|
||||
> - **(c) Colored-tile + mono-glyph design.** `tileColor` = validated `.felhom.yml` `brand_color`
|
||||
> (`#rgb`/`#rrggbb`, new `Metadata.BrandColor`, omitempty) OR a deterministic FNV-1a-of-slug HSL
|
||||
> (fixed S/L, hue per app). Invalid `brand_color` silently falls back to the hash color (the one
|
||||
> §8 exception to no-silent-failure — cosmetic). `tileColor` returns `template.CSS` (we
|
||||
> validate/compute in Go; html/template's CSS filter mangles a legit `hsl()` from a func pipeline).
|
||||
> - **(d) `/` remains the Vezérlőpult.** No role/auth gating — member-role gating is a future arc
|
||||
> (ROADMAP: member role → launcher becomes the member landing page). No catalog app sets
|
||||
> `brand_color` yet (curation parked).
|
||||
> No agent coupling; MinAgent unchanged. 10 new test functions + 4 red-proofs (all observed FAIL then
|
||||
> restored). Gates green (app_row_dedup / template_id / emoji).
|
||||
|
||||
|
||||
> **2026-07-24 — v0.162.0 (R-71a), SHIPPED + deployed BOTH boxes (demo-felhom 9201 + demo-hp 9201
|
||||
> via G1 break-glass), clean+healthy, settle-gate GO line captured on both.** B′ live note: both
|
||||
|
||||
@@ -157,6 +157,11 @@ backups, monitoring and notifications. All Proxmox/disk operations are delegated
|
||||
(traefik/cloudflared/filebrowser) get curated Hungarian display identity from the
|
||||
`inframeta.go` map (name + description + generic `/static/infra-logo.svg` fallback icon);
|
||||
filebrowser is the only infra stack with a customer link (`files.<domain>`).
|
||||
**Universal app placeholder (v0.163.0):** `app_list_row` now DEFAULTS its fallback icon to the
|
||||
embedded `AppPlaceholderSVG` (a 2×2 app-grid glyph, served at `/static/app-placeholder.svg`), so a
|
||||
logo-less app shows a placeholder on every list surface instead of a hidden icon; infra rows still
|
||||
override with the server glyph. The felhom brand mark is never an app placeholder (brand = platform
|
||||
identity only).
|
||||
**Enforcement:** `scripts/template_id_gate.py` (JS element-ID integrity) + `scripts/emoji_gate.py`
|
||||
(no emoji) + `scripts/native_confirm_gate.py` (zero native confirm/prompt) +
|
||||
`scripts/app_row_dedup_gate.py` (row markup single-sourced) + `scripts/mojibake_gate.py`
|
||||
@@ -256,6 +261,23 @@ When a user visits a stopped or undeployed app's subdomain (e.g., `travel.demo-f
|
||||
- **`catchall.html`** — standalone template (no layout, inline CSS) showing the app name, status ("leállítva" / "nincs telepítve" / "nem található"), and links to the controller dashboard or the app's detail page.
|
||||
- **Subdomain links** on the Alkalmazások page are only shown for deployed apps (non-deployed apps have no guaranteed subdomain yet).
|
||||
|
||||
#### Indítópult (app launcher page) (v0.163.0)
|
||||
|
||||
`/launcher` (`launcherHandler` + `templates/launcher.html`) is the **first** sidebar item, above
|
||||
Vezérlőpult; `/` still lands on the Vezérlőpult. It renders a grid of large tappable tiles — one per
|
||||
**openable** deployed app. Openability has a single criterion, shared with the „Megnyitás" button: the
|
||||
stack has a subdomain (env `SUBDOMAIN` > `.felhom.yml` subdomain > `protectedStackSubdomains`),
|
||||
resolved through the extracted `Server.subdomainMap` helper (the dashboard and Alkalmazások pages use
|
||||
the same helper). The controller's own stack is excluded by name.
|
||||
|
||||
Each tile is a colored rounded square: `funcmap.tileColor(slug, brand)` returns a validated
|
||||
`.felhom.yml` **`brand_color`** (`#rgb`/`#rrggbb`, `Metadata.BrandColor`) or, when absent/invalid, a
|
||||
deterministic FNV-1a-of-slug → HSL color (fixed S/L, hue varies per app). The white monochrome logo
|
||||
renders on top of a **monogram** initial (`funcmap.initial`, multibyte-safe); if the logo fails to
|
||||
load the monogram shows through (the launcher does NOT use the app-placeholder here). Operational apps
|
||||
are `<a target="_blank" rel="noopener">` links; stopped/degraded apps render greyed + unclickable with
|
||||
the Hungarian state badge. Empty state links to `/stacks`.
|
||||
|
||||
#### Dashboard "Megnyitás" Button
|
||||
|
||||
Running apps on the Vezérlőpult now show a "Megnyitás ↗" button that opens the app's subdomain in a new tab. The `Subdomains` map is built in `dashboardHandler` from `app.yaml` env or metadata fallback.
|
||||
|
||||
@@ -16,6 +16,9 @@ type Metadata struct {
|
||||
Description string `yaml:"description" json:"description"`
|
||||
Category string `yaml:"category" json:"category"`
|
||||
Subdomain string `yaml:"subdomain" json:"subdomain"`
|
||||
// BrandColor is an optional launcher tile color (#rgb or #rrggbb). Invalid values fall back to
|
||||
// the deterministic slug-derived color (see web.tileColor); no app sets it yet.
|
||||
BrandColor string `yaml:"brand_color,omitempty" json:"brand_color,omitempty"`
|
||||
Slug string `yaml:"slug" json:"slug"`
|
||||
// Lifecycle governs whether this app is OFFERED for new installs. It never affects an app that
|
||||
// is already deployed — a customer running a hidden or abandoned app keeps full function, which
|
||||
|
||||
@@ -75,6 +75,50 @@ func TestAppRow_RestoreLists(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Group D (v0.163.0) — the app_list_row now defaults its fallback icon to the generic app
|
||||
// placeholder, so a logo-less app shows a placeholder glyph instead of the old visibility:hidden
|
||||
// dead-end; an infra caller still overrides it with the server glyph.
|
||||
// COMPANION red-proof (REPORT): revert app_row.html's data-fallback to the old
|
||||
// `{{with .FallbackIcon}}...{{end}}` (attribute absent when no caller value) → the default-present
|
||||
// assertion FAILS.
|
||||
func TestAppRow_DefaultPlaceholderFallback(t *testing.T) {
|
||||
// No FallbackIcon → the placeholder is the default.
|
||||
def := renderPartial(t, "app_list_row", map[string]interface{}{"Slug": "no-logo-app", "Name": "Logótlan"})
|
||||
if !strings.Contains(def, `data-fallback="/static/app-placeholder.svg"`) {
|
||||
t.Errorf("a row without FallbackIcon must default to the app placeholder, got:\n%s", def)
|
||||
}
|
||||
// A caller-provided FallbackIcon (infra) still wins.
|
||||
infra := renderPartial(t, "app_list_row",
|
||||
map[string]interface{}{"Slug": "filebrowser", "Name": "FileBrowser", "FallbackIcon": "/static/infra-logo.svg"})
|
||||
if !strings.Contains(infra, `data-fallback="/static/infra-logo.svg"`) {
|
||||
t.Errorf("an explicit FallbackIcon must be preserved, got:\n%s", infra)
|
||||
}
|
||||
if strings.Contains(infra, "/static/app-placeholder.svg") {
|
||||
t.Error("an infra row must not also carry the app placeholder")
|
||||
}
|
||||
}
|
||||
|
||||
// The placeholder asset is served with the SVG content-type + long cache, like the infra logo.
|
||||
func TestAppPlaceholderRoute(t *testing.T) {
|
||||
s := testPageServer(t)
|
||||
rec := getPage(t, s, "/static/app-placeholder.svg")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("GET /static/app-placeholder.svg = %d", rec.Code)
|
||||
}
|
||||
if ct := rec.Header().Get("Content-Type"); ct != "image/svg+xml" {
|
||||
t.Errorf("content-type = %q, want image/svg+xml", ct)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), "<svg") {
|
||||
t.Error("placeholder body must be an SVG")
|
||||
}
|
||||
}
|
||||
|
||||
// renderPartial renders a single {{define}}'d partial (e.g. app_list_row) in isolation.
|
||||
func renderPartial(t *testing.T, name string, data map[string]interface{}) string {
|
||||
t.Helper()
|
||||
return renderBackupPage(t, name, data)
|
||||
}
|
||||
|
||||
func TestAppRow_Dashboard(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"Page": "dashboard", "Title": "Vezérlőpult",
|
||||
|
||||
@@ -3,15 +3,53 @@ package web
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"html/template"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// hexColorRe matches a valid #rgb / #rrggbb CSS hex color. A brand_color that does not match is
|
||||
// treated as absent (the deterministic slug color is used instead) — see tileColor. This is the
|
||||
// braces to html/template's belt: we never pass an unvalidated brand string into a style attribute,
|
||||
// even though the escaper would neutralize an injection anyway.
|
||||
var hexColorRe = regexp.MustCompile(`^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$`)
|
||||
|
||||
// tileColor returns the launcher tile background for an app. A valid #rgb/#rrggbb brand override is
|
||||
// returned verbatim; otherwise a deterministic HSL is derived from the slug via FNV-1a so the same
|
||||
// app always gets the same color. Saturation/lightness are fixed (tuned for the dark theme with a
|
||||
// white glyph on top); only the hue varies per app.
|
||||
//
|
||||
// The result is template.CSS because BOTH branches are trusted-by-construction: the brand is
|
||||
// validated against hexColorRe here in Go, and the hsl() is fully computed by us. That is the point
|
||||
// of the Go-side validation — html/template's CSS filter mangles a legitimate hsl() from a func
|
||||
// pipeline to ZgotmplZ, so the escaper cannot be the belt here; the regex is. Returning an
|
||||
// UNVALIDATED brand as template.CSS would inject it verbatim (which the Group C red-proof exercises).
|
||||
func tileColor(slug, brand string) template.CSS {
|
||||
if hexColorRe.MatchString(brand) {
|
||||
return template.CSS(brand)
|
||||
}
|
||||
h := fnv.New32a()
|
||||
_, _ = h.Write([]byte(slug))
|
||||
hue := h.Sum32() % 360
|
||||
return template.CSS(fmt.Sprintf("hsl(%d, 45%%, 38%%)", hue))
|
||||
}
|
||||
|
||||
// initial returns the monogram for an app: the first rune of name, upper-cased. Rune-based (not
|
||||
// byte-based) so a multibyte accented initial ("Óra" → "Ó") is not split. Empty name → "?".
|
||||
func initial(name string) string {
|
||||
for _, r := range name {
|
||||
return string(unicode.ToUpper(r))
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
var (
|
||||
webTimezone *time.Location
|
||||
webTimezoneOnce sync.Once
|
||||
@@ -423,6 +461,10 @@ func (s *Server) templateFuncMap() template.FuncMap {
|
||||
// infraMeta resolves a protected infra stack's curated Hungarian identity
|
||||
// (inframeta.go); nil for regular apps — templates branch on it.
|
||||
"infraMeta": infraMetaFor,
|
||||
// tileColor / initial back the Indítópult (launcher) tiles: a deterministic slug-derived
|
||||
// color (or a validated brand_color override) and a multibyte-safe monogram fallback.
|
||||
"tileColor": tileColor,
|
||||
"initial": initial,
|
||||
// pageMatch returns true if currentPage is in the pages slice.
|
||||
// Used to filter page-specific alerts in layout.html.
|
||||
"pageMatch": func(pages []string, currentPage string) bool {
|
||||
|
||||
@@ -187,8 +187,22 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// Build subdomain map for "Megnyitás" buttons
|
||||
data["Subdomains"] = s.subdomainMap(deployedStacks)
|
||||
|
||||
if s.alertManager != nil {
|
||||
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
|
||||
}
|
||||
|
||||
s.executeTemplate(w, r, "dashboard", data)
|
||||
}
|
||||
|
||||
// subdomainMap builds the stack-name → subdomain lookup used by the "Megnyitás" open links (dashboard
|
||||
// + Alkalmazások) and the Indítópult launcher. Priority — unchanged from the two inline copies it
|
||||
// replaces: the deployed app.yaml SUBDOMAIN env wins, then the .felhom.yml Meta.Subdomain, then the
|
||||
// well-known protectedStackSubdomains fallback. A stack with no source of a subdomain gets no entry.
|
||||
func (s *Server) subdomainMap(stackList []stacks.Stack) map[string]string {
|
||||
subdomains := make(map[string]string)
|
||||
for _, stack := range deployedStacks {
|
||||
for _, stack := range stackList {
|
||||
if stack.Deployed {
|
||||
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||||
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
||||
@@ -203,13 +217,74 @@ func (s *Server) dashboardHandler(w http.ResponseWriter, r *http.Request) {
|
||||
subdomains[stack.Name] = sd
|
||||
}
|
||||
}
|
||||
data["Subdomains"] = subdomains
|
||||
|
||||
if s.alertManager != nil {
|
||||
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
|
||||
return subdomains
|
||||
}
|
||||
|
||||
s.executeTemplate(w, r, "dashboard", data)
|
||||
// controllerStackName is the controller's own stack — it IS this dashboard, so it never appears as a
|
||||
// launchable app even if a subdomain mapping somehow exists for it.
|
||||
const controllerStackName = "felhom-controller"
|
||||
|
||||
// LauncherApp is one openable app tile for the Indítópult (launcher) page.
|
||||
type LauncherApp struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
Slug string
|
||||
State stacks.ContainerState
|
||||
Subdomain string
|
||||
OpenPath string
|
||||
BrandColor string
|
||||
}
|
||||
|
||||
// buildLauncherApps builds the sorted launcher tile list from the deployed/protected stacks and the
|
||||
// subdomain lookup. A tile exists exactly when a "Megnyitás" button would — i.e. the stack has a
|
||||
// subdomain entry — minus the controller itself. Sorted by DisplayName (tie-broken by Name) so the
|
||||
// grid order is stable regardless of Go's map iteration order.
|
||||
func buildLauncherApps(stackList []stacks.Stack, subdomains map[string]string) []LauncherApp {
|
||||
var apps []LauncherApp
|
||||
for _, st := range stackList {
|
||||
if st.Name == controllerStackName {
|
||||
continue
|
||||
}
|
||||
sd, ok := subdomains[st.Name]
|
||||
if !ok || sd == "" {
|
||||
continue
|
||||
}
|
||||
dn := st.Meta.DisplayName
|
||||
if dn == "" {
|
||||
dn = st.Name
|
||||
}
|
||||
apps = append(apps, LauncherApp{
|
||||
Name: st.Name,
|
||||
DisplayName: dn,
|
||||
Slug: st.Meta.Slug,
|
||||
State: st.State,
|
||||
Subdomain: sd,
|
||||
OpenPath: st.Meta.OpenPath,
|
||||
BrandColor: st.Meta.BrandColor,
|
||||
})
|
||||
}
|
||||
sort.Slice(apps, func(i, j int) bool {
|
||||
if apps[i].DisplayName == apps[j].DisplayName {
|
||||
return apps[i].Name < apps[j].Name
|
||||
}
|
||||
return apps[i].DisplayName < apps[j].DisplayName
|
||||
})
|
||||
return apps
|
||||
}
|
||||
|
||||
// launcherHandler renders the Indítópult: a grid of large tappable tiles, one per openable deployed
|
||||
// app (subdomain presence is the single openability criterion — see buildLauncherApps). Behind
|
||||
// RequireAuth like every page; the "/" landing page stays the Vezérlőpult.
|
||||
func (s *Server) launcherHandler(w http.ResponseWriter, r *http.Request) {
|
||||
var eligible []stacks.Stack
|
||||
for _, st := range s.stackMgr.GetStacks() {
|
||||
if st.Deployed || st.Protected {
|
||||
eligible = append(eligible, st)
|
||||
}
|
||||
}
|
||||
data := s.baseData("launcher", "Indítópult")
|
||||
data["Apps"] = buildLauncherApps(eligible, s.subdomainMap(eligible))
|
||||
s.executeTemplate(w, r, "launcher", data)
|
||||
}
|
||||
|
||||
// visibleCatalogStacks drops templates that are no longer OFFERED for new installs (lifecycle
|
||||
@@ -263,23 +338,7 @@ func (s *Server) stacksHandler(w http.ResponseWriter, r *http.Request) {
|
||||
data["StorageLabels"] = storageLabels
|
||||
|
||||
// Build effective subdomain lookup (stored env > metadata > well-known fallback)
|
||||
subdomains := make(map[string]string)
|
||||
for _, stack := range s.stackMgr.GetStacks() {
|
||||
if stack.Deployed {
|
||||
if appCfg := s.stackMgr.LoadAppConfigByName(stack.Name); appCfg != nil {
|
||||
if sd, ok := appCfg.Env["SUBDOMAIN"]; ok && sd != "" {
|
||||
subdomains[stack.Name] = sd
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
if stack.Meta.Subdomain != "" {
|
||||
subdomains[stack.Name] = stack.Meta.Subdomain
|
||||
} else if sd, ok := protectedStackSubdomains[stack.Name]; ok {
|
||||
subdomains[stack.Name] = sd
|
||||
}
|
||||
}
|
||||
data["Subdomains"] = subdomains
|
||||
data["Subdomains"] = s.subdomainMap(s.stackMgr.GetStacks())
|
||||
|
||||
s.executeTemplate(w, r, "stacks", data)
|
||||
}
|
||||
|
||||
@@ -52,3 +52,10 @@ func infraMetaFor(stackName string) *InfraMeta {
|
||||
// --text-2 stroke) — the last-resort icon for infra stacks whose logo asset is not
|
||||
// synced. Served at /static/infra-logo.svg; consumed via the app-row FallbackIcon slot.
|
||||
const InfraLogoSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#94A6BF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/></svg>`
|
||||
|
||||
// AppPlaceholderSVG is the generic APP placeholder icon — a 2×2 rounded-square app-grid, matching
|
||||
// InfraLogoSVG's monochrome --text-2 visual language but a distinct glyph (apps, not server racks).
|
||||
// It is the default FallbackIcon on every app-list row for a regular app whose logo asset is missing,
|
||||
// replacing the old visibility:hidden dead-end. NEVER the felhom brand mark: the brand is platform
|
||||
// identity, never an app stand-in (design ruling, CONTEXT.md). Served at /static/app-placeholder.svg.
|
||||
const AppPlaceholderSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#94A6BF" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/></svg>`
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
package web
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
|
||||
)
|
||||
|
||||
// Indítópult (launcher, v0.163.0). The launcher tile exists ⟺ a "Megnyitás" button would exist:
|
||||
// subdomain presence is the single openability criterion (buildLauncherApps). These tests drive the
|
||||
// pure builder (deterministic — no docker) for the filter/sort/exclusion logic, and the production
|
||||
// launcher template for the rendered tile grammar (hrefs, greyed-stopped, monogram, tile color).
|
||||
|
||||
// launcherStacks models Scenario A: two openable apps (env-subdomain + meta-subdomain), one protected
|
||||
// app with a well-known subdomain, one deployed app with NO subdomain, and the controller itself with
|
||||
// a subdomain that must still be excluded by name.
|
||||
func launcherStacks() []stacks.Stack {
|
||||
return []stacks.Stack{
|
||||
{Name: "worker-app", Deployed: true, State: stacks.StateRunning,
|
||||
Meta: stacks.Metadata{Slug: "worker-app", DisplayName: "Worker"}}, // no subdomain → excluded
|
||||
{Name: "mealie", Deployed: true, State: stacks.StateRunning,
|
||||
Meta: stacks.Metadata{Slug: "mealie", DisplayName: "Mealie", Subdomain: "recept"}},
|
||||
{Name: "paperless-ngx", Deployed: true, State: stacks.StateRunning,
|
||||
Meta: stacks.Metadata{Slug: "paperless-ngx", DisplayName: "Paperless"}}, // subdomain via env map
|
||||
{Name: "filebrowser", Protected: true, State: stacks.StateRunning,
|
||||
Meta: stacks.Metadata{Slug: "filebrowser", DisplayName: "FileBrowser"}}, // protectedStackSubdomains
|
||||
{Name: "felhom-controller", Deployed: true, State: stacks.StateRunning,
|
||||
Meta: stacks.Metadata{Slug: "felhom-controller", DisplayName: "Vezérlő", Subdomain: "felhom"}},
|
||||
}
|
||||
}
|
||||
|
||||
func launcherSubdomains() map[string]string {
|
||||
return map[string]string{
|
||||
"mealie": "recept",
|
||||
"paperless-ngx": "papir",
|
||||
"filebrowser": "files",
|
||||
"felhom-controller": "felhom", // present on purpose — must be excluded by name anyway
|
||||
// worker-app: deliberately absent
|
||||
}
|
||||
}
|
||||
|
||||
// Group A — the builder selects exactly the openable apps, sorted by DisplayName, controller excluded.
|
||||
// COMPANION red-proof (REPORT): drop the `if !ok || sd == ""` continue in buildLauncherApps →
|
||||
// worker-app (no subdomain) leaks in and the "worker absent" assertion FAILS.
|
||||
func TestBuildLauncherApps_SelectionAndOrder(t *testing.T) {
|
||||
apps := buildLauncherApps(launcherStacks(), launcherSubdomains())
|
||||
|
||||
var names []string
|
||||
for _, a := range apps {
|
||||
names = append(names, a.Name)
|
||||
}
|
||||
// Openable set = mealie, paperless-ngx, filebrowser — sorted by DisplayName:
|
||||
// FileBrowser < Mealie < Paperless.
|
||||
wantOrder := []string{"filebrowser", "mealie", "paperless-ngx"}
|
||||
if strings.Join(names, ",") != strings.Join(wantOrder, ",") {
|
||||
t.Fatalf("launcher apps = %v, want %v (alphabetical by DisplayName)", names, wantOrder)
|
||||
}
|
||||
for _, a := range apps {
|
||||
if a.Name == "worker-app" {
|
||||
t.Error("worker-app has no subdomain — it must not be launchable")
|
||||
}
|
||||
if a.Name == "felhom-controller" {
|
||||
t.Error("the controller stack must never appear on its own launcher")
|
||||
}
|
||||
}
|
||||
// The subdomain is resolved from the lookup, not the metadata blindly (paperless env=papir).
|
||||
for _, a := range apps {
|
||||
if a.Name == "paperless-ngx" && a.Subdomain != "papir" {
|
||||
t.Errorf("paperless subdomain = %q, want papir (from the lookup)", a.Subdomain)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group A (render) — each openable tile is an <a> to its public URL with OpenPath and target=_blank.
|
||||
func TestLauncherTemplate_OpenableTiles(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu",
|
||||
"Apps": []LauncherApp{
|
||||
{Name: "mealie", DisplayName: "Mealie", Slug: "mealie", State: stacks.StateRunning, Subdomain: "recept"},
|
||||
{Name: "gokapi", DisplayName: "Gokapi", Slug: "gokapi", State: stacks.StateRunning, Subdomain: "fajl", OpenPath: "/admin"},
|
||||
},
|
||||
}
|
||||
html := renderBackupPage(t, "launcher", data)
|
||||
if !strings.Contains(html, `href="https://recept.demo-felhom.eu"`) {
|
||||
t.Error("mealie tile must link to its public URL")
|
||||
}
|
||||
if !strings.Contains(html, `href="https://fajl.demo-felhom.eu/admin"`) {
|
||||
t.Error("gokapi tile must append OpenPath to its public URL")
|
||||
}
|
||||
if !strings.Contains(html, `target="_blank"`) || !strings.Contains(html, `rel="noopener"`) {
|
||||
t.Error("openable tiles must open in a new tab with rel=noopener")
|
||||
}
|
||||
}
|
||||
|
||||
// Group B — a stopped app renders a greyed tile with the state badge and NO href (Scenario B).
|
||||
// COMPANION red-proof (REPORT): in launcher.html render the <a> branch unconditionally (drop the
|
||||
// isOperational guard) → the "no href for the stopped tile" assertion FAILS.
|
||||
func TestLauncherTemplate_StoppedTileGreyedNoLink(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu",
|
||||
"Apps": []LauncherApp{
|
||||
{Name: "jellyfin", DisplayName: "Jellyfin", Slug: "jellyfin", State: stacks.StateStopped, Subdomain: "media"},
|
||||
},
|
||||
}
|
||||
html := renderBackupPage(t, "launcher", data)
|
||||
if strings.Contains(html, "media.demo-felhom.eu") {
|
||||
t.Error("a stopped app must not be a link (no href to its subdomain)")
|
||||
}
|
||||
if !strings.Contains(html, "launch-cell--off") || !strings.Contains(html, "launch-tile--off") {
|
||||
t.Error("a stopped tile must carry the greyed classes")
|
||||
}
|
||||
if !strings.Contains(html, "Leállítva") {
|
||||
t.Error("a stopped tile must show its Hungarian stateLabel badge")
|
||||
}
|
||||
}
|
||||
|
||||
// Group C — tile color safety: valid brand passes through, an injection payload becomes a hash HSL
|
||||
// and never appears verbatim in the rendered HTML.
|
||||
// COMPANION red-proof (REPORT): make tileColor return `brand` unvalidated → the payload substring
|
||||
// appears in the rendered style and the "payload absent" assertion FAILS.
|
||||
func TestLauncherTemplate_TileColorSafety(t *testing.T) {
|
||||
const payload = "red;background:url(x)"
|
||||
data := map[string]interface{}{
|
||||
"Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu",
|
||||
"Apps": []LauncherApp{
|
||||
{Name: "jelly", DisplayName: "Jelly", Slug: "jelly", State: stacks.StateRunning, Subdomain: "j", BrandColor: "#00A4DC"},
|
||||
{Name: "evil", DisplayName: "Evil", Slug: "evil", State: stacks.StateRunning, Subdomain: "e", BrandColor: payload},
|
||||
},
|
||||
}
|
||||
html := renderBackupPage(t, "launcher", data)
|
||||
if !strings.Contains(html, "background: #00A4DC") {
|
||||
t.Error("a valid brand_color must pass through verbatim as the tile background")
|
||||
}
|
||||
if strings.Contains(html, payload) {
|
||||
t.Errorf("the injection payload must never reach the rendered HTML, got it in:\n%s", html)
|
||||
}
|
||||
if !strings.Contains(html, "background: hsl(") {
|
||||
t.Error("an invalid brand_color must fall back to the deterministic hash HSL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTileColor(t *testing.T) {
|
||||
// Valid overrides pass through verbatim.
|
||||
for _, ok := range []string{"#fff", "#FFF", "#00A4DC", "#00a4dc"} {
|
||||
if got := string(tileColor("slug", ok)); got != ok {
|
||||
t.Errorf("tileColor(_, %q) = %q, want passthrough", ok, got)
|
||||
}
|
||||
}
|
||||
// Invalid overrides fall back to a deterministic HSL derived from the slug only.
|
||||
for _, bad := range []string{"", "red", "#12", "#1234", "red;background:url(x)", "0083D8"} {
|
||||
got := string(tileColor("nextcloud", bad))
|
||||
if !strings.HasPrefix(got, "hsl(") {
|
||||
t.Errorf("tileColor(_, %q) = %q, want hsl() fallback", bad, got)
|
||||
}
|
||||
}
|
||||
// Determinism: same slug ⇒ same color; different slugs generally differ.
|
||||
if tileColor("nextcloud", "") != tileColor("nextcloud", "") {
|
||||
t.Error("tileColor must be deterministic for a given slug")
|
||||
}
|
||||
if tileColor("nextcloud", "") == tileColor("paperless-ngx", "") {
|
||||
t.Error("distinct slugs collided (acceptable in theory, but these two must not for the fixture)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitial(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"Óra": "Ó", // multibyte — must not split the byte
|
||||
"nextcloud": "N",
|
||||
"7days": "7",
|
||||
"": "?",
|
||||
"árvíztűrő": "Á",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := initial(in); got != want {
|
||||
t.Errorf("initial(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Group E — zero openable apps ⇒ the calm empty state with the /stacks link.
|
||||
func TestLauncherTemplate_EmptyState(t *testing.T) {
|
||||
data := map[string]interface{}{
|
||||
"Page": "launcher", "Title": "Indítópult", "Domain": "demo-felhom.eu",
|
||||
"Apps": []LauncherApp{},
|
||||
}
|
||||
html := renderBackupPage(t, "launcher", data)
|
||||
if !strings.Contains(html, "Még nincs telepített alkalmazás.") {
|
||||
t.Error("empty launcher must show the calm empty-state copy")
|
||||
}
|
||||
if !strings.Contains(html, `href="/stacks"`) {
|
||||
t.Error("empty launcher must link to /stacks")
|
||||
}
|
||||
}
|
||||
|
||||
// End-to-end wiring — GET /launcher through the real ServeHTTP route with disk-seeded stacks:
|
||||
// the route is registered, the handler builds tiles, the nav marks Indítópult active, and a
|
||||
// subdomain-less app never appears. State is not asserted here (docker-dependent); the deterministic
|
||||
// selection/order/state assertions live in the builder + template tests above.
|
||||
func TestLauncherRoute_EndToEnd(t *testing.T) {
|
||||
s := testPageServer(t)
|
||||
stacksDir := s.cfg.Paths.StacksDir
|
||||
|
||||
// openable: meta subdomain present
|
||||
writeStack(t, stacksDir, "recept-app", "display_name: Receptek\nsubdomain: recept\n", true)
|
||||
// not openable: no subdomain anywhere
|
||||
writeStack(t, stacksDir, "worker-app", "display_name: Worker\n", true)
|
||||
|
||||
if err := s.stackMgr.ScanStacks(); err != nil {
|
||||
t.Logf("ScanStacks (docker status step may fail on a docker-less host): %v", err)
|
||||
}
|
||||
if _, ok := s.stackMgr.GetStack("recept-app"); !ok {
|
||||
t.Fatal("recept-app not discovered by ScanStacks")
|
||||
}
|
||||
|
||||
rec := getPage(t, s, "/launcher")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("GET /launcher = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
body := rec.Body.String()
|
||||
if !strings.Contains(body, "Receptek") {
|
||||
t.Error("the openable app must appear on the launcher")
|
||||
}
|
||||
if strings.Contains(body, "Worker") {
|
||||
t.Error("a subdomain-less app must not appear on the launcher")
|
||||
}
|
||||
// Nav: Indítópult present and marked active on this page.
|
||||
if !strings.Contains(body, `href="/launcher" class="active"`) {
|
||||
t.Error("the Indítópult nav entry must be marked active on /launcher")
|
||||
}
|
||||
}
|
||||
|
||||
// writeStack plants a minimal deployable stack dir (.felhom.yml + compose + app.yaml deployed).
|
||||
func writeStack(t *testing.T, stacksDir, name, meta string, deployed bool) {
|
||||
t.Helper()
|
||||
dir := filepath.Join(stacksDir, name)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".felhom.yml"), []byte(meta), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services: {}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deployed {
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.yaml"), []byte("deployed: true\nenv: {}\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -345,6 +345,8 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
s.handleClaimRequestNewCode(w, r)
|
||||
case path == "/" || path == "/dashboard":
|
||||
s.dashboardHandler(w, r)
|
||||
case path == "/launcher":
|
||||
s.launcherHandler(w, r)
|
||||
case path == "/stacks":
|
||||
s.stacksHandler(w, r)
|
||||
case path == "/backups":
|
||||
@@ -491,6 +493,10 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
fmt.Fprint(w, InfraLogoSVG)
|
||||
case path == "/static/app-placeholder.svg":
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "public, max-age=86400")
|
||||
fmt.Fprint(w, AppPlaceholderSVG)
|
||||
case strings.HasPrefix(path, "/static/assets/"):
|
||||
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
|
||||
case strings.HasPrefix(path, "/apps/"):
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user