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:
@@ -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
|
||||
return subdomains
|
||||
}
|
||||
|
||||
if s.alertManager != nil {
|
||||
data["DiskWarnings"] = s.alertManager.GetInlineAlerts("dashboard")
|
||||
// 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
|
||||
}
|
||||
|
||||
s.executeTemplate(w, r, "dashboard", data)
|
||||
// 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