AppManager: clean up UI

Change-Id: I8119ea81c80ff6165f4217dfdf9837e776703fc0
diff --git a/core/installer/app.go b/core/installer/app.go
index cec7cbf..11ca8cd 100644
--- a/core/installer/app.go
+++ b/core/installer/app.go
@@ -384,8 +384,9 @@
 )
 
 type App interface {
-	Type() AppType
 	Name() string
+	Type() AppType
+	Slug() string
 	Description() string
 	Icon() template.HTML
 	Schema() Schema
@@ -529,7 +530,7 @@
 	if err := config.Decode(&cfg); err != nil {
 		return cueApp{}, err
 	}
-	schema, err := NewCueSchema(config.LookupPath(cue.ParsePath("input")))
+	schema, err := NewCueSchema("input", config.LookupPath(cue.ParsePath("input")))
 	if err != nil {
 		return cueApp{}, err
 	}
@@ -556,6 +557,10 @@
 	return a.name
 }
 
+func (a cueApp) Slug() string {
+	return strings.ReplaceAll(strings.ToLower(a.name), " ", "-")
+}
+
 func (a cueApp) Description() string {
 	return a.description
 }
@@ -574,7 +579,7 @@
 
 func (a cueApp) render(values map[string]any) (rendered, error) {
 	ret := rendered{
-		Name:      a.Name(),
+		Name:      a.Slug(),
 		Resources: make(CueAppData),
 		Ports:     make([]PortForward, 0),
 		Data:      a.data,
@@ -670,7 +675,7 @@
 	return EnvAppRendered{
 		rendered: ret,
 		Config: AppInstanceConfig{
-			AppId:   a.Name(),
+			AppId:   a.Slug(),
 			Env:     env,
 			Release: release,
 			Values:  values,
@@ -709,7 +714,7 @@
 	return InfraAppRendered{
 		rendered: ret,
 		Config: InfraAppInstanceConfig{
-			AppId:   a.Name(),
+			AppId:   a.Slug(),
 			Infra:   infra,
 			Release: release,
 			Values:  values,
diff --git a/core/installer/app_repository.go b/core/installer/app_repository.go
index 6acde70..5727e64 100644
--- a/core/installer/app_repository.go
+++ b/core/installer/app_repository.go
@@ -17,22 +17,24 @@
 //go:embed values-tmpl
 var valuesTmpls embed.FS
 
-var storeAppConfigs = []string{
-	"values-tmpl/jellyfin.cue",
-	// "values-tmpl/maddy.cue",
-	"values-tmpl/matrix.cue",
-	"values-tmpl/penpot.cue",
-	"values-tmpl/pihole.cue",
-	"values-tmpl/qbittorrent.cue",
-	"values-tmpl/rpuppy.cue",
-	"values-tmpl/soft-serve.cue",
-	"values-tmpl/vaultwarden.cue",
+var storeEnvAppConfigs = []string{
 	"values-tmpl/url-shortener.cue",
+	"values-tmpl/matrix.cue",
+	"values-tmpl/vaultwarden.cue",
+	"values-tmpl/open-project.cue",
 	"values-tmpl/gerrit.cue",
 	"values-tmpl/jenkins.cue",
 	"values-tmpl/zot.cue",
-	"values-tmpl/open-project.cue",
-	// TODO(gio): should be part of env infra
+	"values-tmpl/penpot.cue",
+	"values-tmpl/soft-serve.cue",
+	"values-tmpl/pihole.cue",
+	// "values-tmpl/maddy.cue",
+	"values-tmpl/qbittorrent.cue",
+	"values-tmpl/jellyfin.cue",
+	"values-tmpl/rpuppy.cue",
+}
+
+var envAppConfigs = []string{
 	"values-tmpl/certificate-issuer-private.cue",
 	"values-tmpl/certificate-issuer-public.cue",
 	"values-tmpl/appmanager.cue",
@@ -75,7 +77,7 @@
 
 func (r InMemoryAppRepository) Find(name string) (App, error) {
 	for _, a := range r.apps {
-		if a.Name() == name {
+		if a.Slug() == name {
 			return a, nil
 		}
 	}
@@ -89,13 +91,20 @@
 func CreateAllApps() []App {
 	return append(
 		createInfraApps(),
-		CreateStoreApps()...,
+		append(
+			CreateEnvApps(storeEnvAppConfigs),
+			CreateEnvApps(envAppConfigs)...,
+		)...,
 	)
 }
 
 func CreateStoreApps() []App {
+	return CreateEnvApps(storeEnvAppConfigs)
+}
+
+func CreateEnvApps(configs []string) []App {
 	ret := make([]App, 0)
-	for _, cfgFile := range storeAppConfigs {
+	for _, cfgFile := range configs {
 		contents, err := valuesTmpls.ReadFile(cfgFile)
 		if err != nil {
 			panic(err)
diff --git a/core/installer/app_test.go b/core/installer/app_test.go
index 497c235..9b59d1c 100644
--- a/core/installer/app_test.go
+++ b/core/installer/app_test.go
@@ -26,7 +26,7 @@
 
 func TestAuthProxyEnabled(t *testing.T) {
 	r := NewInMemoryAppRepository(CreateAllApps())
-	for _, app := range []string{"rpuppy", "Pi-hole", "url-shortener"} {
+	for _, app := range []string{"rpuppy", "pi-hole", "url-shortener"} {
 		a, err := FindEnvApp(r, app)
 		if err != nil {
 			t.Fatal(err)
@@ -57,7 +57,7 @@
 
 func TestAuthProxyDisabled(t *testing.T) {
 	r := NewInMemoryAppRepository(CreateAllApps())
-	for _, app := range []string{"rpuppy", "Pi-hole", "url-shortener"} {
+	for _, app := range []string{"rpuppy", "pi-hole", "url-shortener"} {
 		a, err := FindEnvApp(r, app)
 		if err != nil {
 			t.Fatal(err)
diff --git a/core/installer/bootstrapper.go b/core/installer/bootstrapper.go
index 298efc3..e8b77ec 100644
--- a/core/installer/bootstrapper.go
+++ b/core/installer/bootstrapper.go
@@ -417,7 +417,7 @@
 			return err
 		}
 		namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
-		appDir := filepath.Join("/infrastructure", app.Name())
+		appDir := filepath.Join("/infrastructure", app.Slug())
 		return mgr.Install(app, appDir, namespace, map[string]any{})
 	}
 	appsToInstall := []string{
@@ -511,7 +511,7 @@
 		return err
 	}
 	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
-	appDir := filepath.Join("/infrastructure", app.Name())
+	appDir := filepath.Join("/infrastructure", app.Slug())
 	return mgr.Install(app, appDir, namespace, map[string]any{
 		"repoIP":        env.ServiceIPs.ConfigRepo,
 		"repoPort":      22,
@@ -537,7 +537,7 @@
 		return err
 	}
 	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
-	appDir := filepath.Join("/infrastructure", app.Name())
+	appDir := filepath.Join("/infrastructure", app.Slug())
 	return mgr.Install(app, appDir, namespace, map[string]any{
 		"sshPrivateKey": string(keys.RawPrivateKey()),
 	})
@@ -549,7 +549,7 @@
 		return err
 	}
 	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
-	appDir := filepath.Join("/infrastructure", app.Name())
+	appDir := filepath.Join("/infrastructure", app.Slug())
 	return mgr.Install(app, appDir, namespace, map[string]any{})
 }
 
@@ -559,7 +559,7 @@
 		return err
 	}
 	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
-	appDir := filepath.Join("/infrastructure", app.Name())
+	appDir := filepath.Join("/infrastructure", app.Slug())
 	return mgr.Install(app, appDir, namespace, map[string]any{
 		"servers": []EnvDNS{},
 	})
@@ -571,7 +571,7 @@
 		return err
 	}
 	namespace := fmt.Sprintf("%s-%s", env.InfraName, app.Namespace())
-	appDir := filepath.Join("/infrastructure", app.Name())
+	appDir := filepath.Join("/infrastructure", app.Slug())
 	return mgr.Install(app, appDir, namespace, map[string]any{})
 }
 
diff --git a/core/installer/derived.go b/core/installer/derived.go
index 3cc1afb..bc7d7f8 100644
--- a/core/installer/derived.go
+++ b/core/installer/derived.go
@@ -50,7 +50,9 @@
 
 func deriveValues(values any, schema Schema, networks []Network) (map[string]any, error) {
 	ret := make(map[string]any)
-	for k, def := range schema.Fields() {
+	for _, f := range schema.Fields() {
+		k := f.Name
+		def := f.Schema
 		// TODO(gio): validate that it is map
 		v, ok := values.(map[string]any)[k]
 		// TODO(gio): if missing use default value
@@ -113,7 +115,9 @@
 
 func derivedToConfig(derived map[string]any, schema Schema) (map[string]any, error) {
 	ret := make(map[string]any)
-	for k, def := range schema.Fields() {
+	for _, f := range schema.Fields() {
+		k := f.Name
+		def := f.Schema
 		v, ok := derived[k]
 		// TODO(gio): if missing use default value
 		if !ok {
diff --git a/core/installer/schema.go b/core/installer/schema.go
index add5ceb..a0acfc3 100644
--- a/core/installer/schema.go
+++ b/core/installer/schema.go
@@ -21,23 +21,34 @@
 	KindArrayString      = 8
 )
 
+type Field struct {
+	Name   string
+	Schema Schema
+}
+
 type Schema interface {
+	Name() string
 	Kind() Kind
-	Fields() map[string]Schema
+	Fields() []Field
+	Advanced() bool
 }
 
 var AuthSchema Schema = structSchema{
-	fields: map[string]Schema{
-		"enabled": basicSchema{KindBoolean},
-		"groups":  basicSchema{KindString},
+	name: "Auth",
+	fields: []Field{
+		Field{"enabled", basicSchema{"Enabled", KindBoolean, false}},
+		Field{"groups", basicSchema{"Groups", KindString, false}},
 	},
+	advanced: false,
 }
 
 var SSHKeySchema Schema = structSchema{
-	fields: map[string]Schema{
-		"public":  basicSchema{KindString},
-		"private": basicSchema{KindString},
+	name: "SSH Key",
+	fields: []Field{
+		Field{"public", basicSchema{"Public Key", KindString, false}},
+		Field{"private", basicSchema{"Private Key", KindString, false}},
 	},
+	advanced: true,
 }
 
 const networkSchema = `
@@ -116,60 +127,84 @@
 }
 
 type basicSchema struct {
-	kind Kind
+	name     string
+	kind     Kind
+	advanced bool
+}
+
+func (s basicSchema) Name() string {
+	return s.name
 }
 
 func (s basicSchema) Kind() Kind {
 	return s.kind
 }
 
-func (s basicSchema) Fields() map[string]Schema {
+func (s basicSchema) Fields() []Field {
 	return nil
 }
 
+func (s basicSchema) Advanced() bool {
+	return s.advanced
+}
+
 type structSchema struct {
-	fields map[string]Schema
+	name     string
+	fields   []Field
+	advanced bool
+}
+
+func (s structSchema) Name() string {
+	return s.name
 }
 
 func (s structSchema) Kind() Kind {
 	return KindStruct
 }
 
-func (s structSchema) Fields() map[string]Schema {
+func (s structSchema) Fields() []Field {
 	return s.fields
 }
 
-func NewCueSchema(v cue.Value) (Schema, error) {
+func (s structSchema) Advanced() bool {
+	return s.advanced
+}
+
+func NewCueSchema(name string, v cue.Value) (Schema, error) {
+	nameAttr := v.Attribute("name")
+	if nameAttr.Err() == nil {
+		name = nameAttr.Contents()
+	}
 	switch v.IncompleteKind() {
 	case cue.StringKind:
-		return basicSchema{KindString}, nil
+		return basicSchema{name, KindString, false}, nil
 	case cue.BoolKind:
-		return basicSchema{KindBoolean}, nil
+		return basicSchema{name, KindBoolean, false}, nil
 	case cue.NumberKind:
-		return basicSchema{KindNumber}, nil
+		return basicSchema{name, KindNumber, false}, nil
 	case cue.IntKind:
-		return basicSchema{KindInt}, nil
+		return basicSchema{name, KindInt, false}, nil
 	case cue.ListKind:
-		return basicSchema{KindArrayString}, nil
+		return basicSchema{name, KindArrayString, false}, nil
 	case cue.StructKind:
 		if isNetwork(v) {
-			return basicSchema{KindNetwork}, nil
+			return basicSchema{name, KindNetwork, false}, nil
 		} else if isAuth(v) {
-			return basicSchema{KindAuth}, nil
+			return basicSchema{name, KindAuth, false}, nil
 		} else if isSSHKey(v) {
-			return basicSchema{KindSSHKey}, nil
+			return basicSchema{name, KindSSHKey, true}, nil
 		}
-		s := structSchema{make(map[string]Schema)}
+		s := structSchema{name, make([]Field, 0), false}
 		f, err := v.Fields(cue.Schema())
 		if err != nil {
 			return nil, err
 		}
 		for f.Next() {
-			scm, err := NewCueSchema(f.Value())
+			scm, err := NewCueSchema(f.Selector().String(), f.Value())
 			if err != nil {
 				return nil, err
 			}
-			s.fields[f.Selector().String()] = scm
+			s.fields = append(s.fields, Field{f.Selector().String(), scm})
 		}
 		return s, nil
 	default:
diff --git a/core/installer/tasks/dns.go b/core/installer/tasks/dns.go
index 51b066a..1316dc0 100644
--- a/core/installer/tasks/dns.go
+++ b/core/installer/tasks/dns.go
@@ -46,7 +46,7 @@
 			if err != nil {
 				return err
 			}
-			instanceId := app.Name()
+			instanceId := app.Slug()
 			appDir := fmt.Sprintf("/apps/%s", instanceId)
 			namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 			if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
diff --git a/core/installer/tasks/infra.go b/core/installer/tasks/infra.go
index 0327e4c..746b9cf 100644
--- a/core/installer/tasks/infra.go
+++ b/core/installer/tasks/infra.go
@@ -122,7 +122,7 @@
 				return err
 			}
 			{
-				instanceId := fmt.Sprintf("%s-ingress-private", app.Name())
+				instanceId := fmt.Sprintf("%s-ingress-private", app.Slug())
 				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s-ingress-private", env.NamespacePrefix, app.Namespace())
 				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
@@ -136,7 +136,7 @@
 				}
 			}
 			{
-				instanceId := fmt.Sprintf("%s-headscale", app.Name())
+				instanceId := fmt.Sprintf("%s-headscale", app.Slug())
 				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s-ingress-private", env.NamespacePrefix, app.Namespace())
 				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
@@ -150,7 +150,7 @@
 				}
 			}
 			{
-				instanceId := app.Name()
+				instanceId := app.Slug()
 				appDir := fmt.Sprintf("/apps/%s", instanceId)
 				namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 				if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
@@ -180,7 +180,7 @@
 			if err != nil {
 				return err
 			}
-			instanceId := app.Name()
+			instanceId := app.Slug()
 			appDir := fmt.Sprintf("/apps/%s", instanceId)
 			namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 			if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
@@ -205,7 +205,7 @@
 		if err != nil {
 			return err
 		}
-		instanceId := app.Name()
+		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{}); err != nil {
@@ -218,7 +218,7 @@
 		if err != nil {
 			return err
 		}
-		instanceId := app.Name()
+		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{}); err != nil {
@@ -235,7 +235,7 @@
 		if err != nil {
 			return err
 		}
-		instanceId := app.Name()
+		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
@@ -259,7 +259,7 @@
 		if err != nil {
 			return err
 		}
-		instanceId := app.Name()
+		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
@@ -283,7 +283,7 @@
 		if err != nil {
 			return err
 		}
-		instanceId := app.Name()
+		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
@@ -319,7 +319,7 @@
 		if err != nil {
 			return err
 		}
-		instanceId := app.Name()
+		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
@@ -355,7 +355,7 @@
 		if err != nil {
 			return err
 		}
-		instanceId := app.Name()
+		instanceId := app.Slug()
 		appDir := fmt.Sprintf("/apps/%s", instanceId)
 		namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 		if err := st.appManager.Install(app, instanceId, appDir, namespace, map[string]any{
diff --git a/core/installer/values-tmpl/appmanager.cue b/core/installer/values-tmpl/appmanager.cue
index 7c110b5..b710c55 100644
--- a/core/installer/values-tmpl/appmanager.cue
+++ b/core/installer/values-tmpl/appmanager.cue
@@ -8,7 +8,7 @@
 	authGroups: string
 }
 
-name: "app-manager"
+name: "App Manager"
 namespace: "appmanager"
 
 _subdomain: "apps"
diff --git a/core/installer/values-tmpl/gerrit.cue b/core/installer/values-tmpl/gerrit.cue
index d697047..edfcf10 100644
--- a/core/installer/values-tmpl/gerrit.cue
+++ b/core/installer/values-tmpl/gerrit.cue
@@ -1,13 +1,13 @@
 input: {
-	network: #Network
-	subdomain: string
+	network: #Network @name(Network)
+	subdomain: string @name(Subdomain)
 	key: #SSHKey
-	sshPort: int
+	sshPort: int @name(SSH Port)
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
 
-name: "gerrit"
+name: "Gerrit"
 namespace: "app-gerrit"
 readme: "gerrit"
 description: "Gerrit Code Review is a web-based code review tool built on Git version control. Gerrit provides a framework you and your teams can use to review code before it becomes part of the code base. Gerrit works equally well in open source projects that limit the number of users who can approve changes (typical in open source software development) and in projects in which all contributors are trusted."
diff --git a/core/installer/values-tmpl/jellyfin.cue b/core/installer/values-tmpl/jellyfin.cue
index b60be26..9c59d20 100644
--- a/core/installer/values-tmpl/jellyfin.cue
+++ b/core/installer/values-tmpl/jellyfin.cue
@@ -1,6 +1,6 @@
 input: {
-	network: #Network
-	subdomain: string
+	network: #Network @name(Network)
+	subdomain: string @name(Subdomain)
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
diff --git a/core/installer/values-tmpl/jenkins.cue b/core/installer/values-tmpl/jenkins.cue
index c8ae998..f705b34 100644
--- a/core/installer/values-tmpl/jenkins.cue
+++ b/core/installer/values-tmpl/jenkins.cue
@@ -1,11 +1,11 @@
 input: {
-	network: #Network
-	subdomain: string
+	network: #Network @name(Network)
+	subdomain: string @name(Subdomain)
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
 
-name: "jenkins"
+name: "Jenkins"
 namespace: "app-jenkins"
 readme: "Jenkins CI/CD"
 description: "Build great things at any scale. The leading open source automation server, Jenkins provides hundreds of plugins to support building, deploying and automating any project."
diff --git a/core/installer/values-tmpl/launcher.cue b/core/installer/values-tmpl/launcher.cue
index 081d46a..9761464 100644
--- a/core/installer/values-tmpl/launcher.cue
+++ b/core/installer/values-tmpl/launcher.cue
@@ -10,7 +10,7 @@
 _subdomain: "launcher"
 _domain: "\(_subdomain).\(networks.public.domain)"
 
-name: "launcher"
+name: "Launcher"
 namespace: "core-installer-welcome-launcher"
 readme: "App Launcher application will be installed on Private or Public network and be accessible at https://\(_domain)"
 description: "The application is a App launcher, designed to run all accessible applications. Can be configured to be reachable only from private network or publicly."
diff --git a/core/installer/values-tmpl/matrix.cue b/core/installer/values-tmpl/matrix.cue
index 97b3aca..a236a16 100644
--- a/core/installer/values-tmpl/matrix.cue
+++ b/core/installer/values-tmpl/matrix.cue
@@ -1,6 +1,6 @@
 input: {
-	network: #Network
-	subdomain: string
+	network: #Network @name(Network)
+	subdomain: string @name(Subdomain)
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
diff --git a/core/installer/values-tmpl/memberships.cue b/core/installer/values-tmpl/memberships.cue
index aa0ac59..53082f0 100644
--- a/core/installer/values-tmpl/memberships.cue
+++ b/core/installer/values-tmpl/memberships.cue
@@ -5,7 +5,7 @@
 _subdomain: "memberships"
 _domain: "\(_subdomain).\(global.privateDomain)"
 
-name: "memberships"
+name: "Memberships"
 namespace: "core-auth-memberships"
 readme: "Memberships application will be installed on Private network and be accessible at https://\(_domain)"
 description: "The application is a membership management system designed to facilitate the organization and administration of groups and memberships. Can be configured to be reachable only from private network or publicly."
diff --git a/core/installer/values-tmpl/open-project.cue b/core/installer/values-tmpl/open-project.cue
index cab3a54..5428a0d 100644
--- a/core/installer/values-tmpl/open-project.cue
+++ b/core/installer/values-tmpl/open-project.cue
@@ -1,6 +1,6 @@
 input: {
-	network: #Network
-	subdomain: string
+	network: #Network @name(Network)
+	subdomain: string @name(Subdomain)
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
diff --git a/core/installer/values-tmpl/penpot.cue b/core/installer/values-tmpl/penpot.cue
index 0dcd7c7..d35ccc9 100644
--- a/core/installer/values-tmpl/penpot.cue
+++ b/core/installer/values-tmpl/penpot.cue
@@ -1,6 +1,6 @@
 input: {
-	network: #Network
-	subdomain: string
+	network: #Network @name(Network)
+	subdomain: string @name(Subdomain)
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
@@ -9,7 +9,7 @@
 namespace: "app-penpot"
 readme: "penpot application will be installed on \(input.network.name) network and be accessible to any user on https://\(_domain)"
 description: "Penpot is the first Open Source design and prototyping platform meant for cross-domain teams. Non dependent on operating systems, Penpot is web based and works with open standards (SVG). Penpot invites designers all over the world to fall in love with open source while getting developers excited about the design process in return."
-icon: "<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'><path fill='currentColor' d='M7.654 0L5.13 3.554v2.01L2.934 6.608l-.02-.009v13.109l8.563 4.045L12 24l.523-.247l8.563-4.045V6.6l-.017.008l-2.196-1.045V3.555l-.077-.108L16.349.001l-2.524 3.554v.004L11.989.973l-1.823 2.566l-.065-.091zm.447 2.065l.976 1.374H6.232l.964-1.358zm8.694 0l.976 1.374h-2.845l.965-1.358zm-4.36.971l.976 1.375h-2.845l.965-1.359zM5.962 4.132h1.35v4.544l-1.35-.638Zm2.042 0h1.343v5.506l-1.343-.635zm6.652 0h1.35V9l-1.35.637zm2.042 0h1.343v3.905l-1.343.634zm-6.402.972h1.35v5.62l-1.35-.638zm2.042 0h1.343v4.993l-1.343.634zm6.534 1.493l1.188.486l-1.188.561zM5.13 6.6v1.047l-1.187-.561ZM3.96 8.251l7.517 3.55v10.795l-7.516-3.55zm16.08 0v10.794l-7.517 3.55V11.802z'/></svg>"
+icon: "<svg xmlns='http://www.w3.org/2000/svg' width='50' height='50' viewBox='0 0 24 24'><path fill='currentColor' d='M7.654 0L5.13 3.554v2.01L2.934 6.608l-.02-.009v13.109l8.563 4.045L12 24l.523-.247l8.563-4.045V6.6l-.017.008l-2.196-1.045V3.555l-.077-.108L16.349.001l-2.524 3.554v.004L11.989.973l-1.823 2.566l-.065-.091zm.447 2.065l.976 1.374H6.232l.964-1.358zm8.694 0l.976 1.374h-2.845l.965-1.358zm-4.36.971l.976 1.375h-2.845l.965-1.359zM5.962 4.132h1.35v4.544l-1.35-.638Zm2.042 0h1.343v5.506l-1.343-.635zm6.652 0h1.35V9l-1.35.637zm2.042 0h1.343v3.905l-1.343.634zm-6.402.972h1.35v5.62l-1.35-.638zm2.042 0h1.343v4.993l-1.343.634zm6.534 1.493l1.188.486l-1.188.561zM5.13 6.6v1.047l-1.187-.561ZM3.96 8.251l7.517 3.55v10.795l-7.516-3.55zm16.08 0v10.794l-7.517 3.55V11.802z'/></svg>"
 
 images: {
 	postgres: {
diff --git a/core/installer/values-tmpl/pihole.cue b/core/installer/values-tmpl/pihole.cue
index cbce3c0..37a44d5 100644
--- a/core/installer/values-tmpl/pihole.cue
+++ b/core/installer/values-tmpl/pihole.cue
@@ -1,7 +1,7 @@
 input: {
-	network: #Network
-	subdomain: string
-	auth: #Auth
+	network: #Network @name(Network)
+	subdomain: string @name(Subdomain)
+	auth: #Auth @name(Authentication)
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
diff --git a/core/installer/values-tmpl/qbittorrent.cue b/core/installer/values-tmpl/qbittorrent.cue
index 8c913fd..2309d10 100644
--- a/core/installer/values-tmpl/qbittorrent.cue
+++ b/core/installer/values-tmpl/qbittorrent.cue
@@ -1,11 +1,11 @@
 input: {
-	network: #Network
-	subdomain: string
+	network: #Network @name(Network)
+	subdomain: string @name(Subdomain)
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
 
-name: "qbitorrent"
+name: "qBitorrent"
 namespace: "app-qbittorrent"
 readme: "qbittorrent application will be installed on \(input.network.name) network and be accessible to any user on https://\(_domain)"
 description: "qBittorrent is a cross-platform free and open-source BitTorrent client written in native C++. It relies on Boost, Qt 6 toolkit and the libtorrent-rasterbar library, with an optional search engine written in Python."
diff --git a/core/installer/values-tmpl/rpuppy.cue b/core/installer/values-tmpl/rpuppy.cue
index 5dedad6..25e176d 100644
--- a/core/installer/values-tmpl/rpuppy.cue
+++ b/core/installer/values-tmpl/rpuppy.cue
@@ -1,12 +1,12 @@
 input: {
-	network: #Network
-	subdomain: string
-	auth: #Auth
+	network: #Network @name(Network)
+	subdomain: string @name(Subdomain)
+	auth: #Auth @name(Authentication)
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
 
-name: "rpuppy"
+name: "rPuppy"
 namespace: "app-rpuppy"
 readme: "rpuppy application will be installed on \(input.network.name) network and be accessible to any user on https://\(_domain)"
 description: "Delights users with randomly generate puppy pictures. Can be configured to be reachable only from private network or publicly."
diff --git a/core/installer/values-tmpl/soft-serve.cue b/core/installer/values-tmpl/soft-serve.cue
index 586490b..3d34615 100644
--- a/core/installer/values-tmpl/soft-serve.cue
+++ b/core/installer/values-tmpl/soft-serve.cue
@@ -1,6 +1,6 @@
 input: {
-	subdomain: string
-	adminKey: string
+	subdomain: string @name(Subdomain)
+	adminKey: string @name(Admin SSH Public Key)
 }
 
 _domain: "\(input.subdomain).\(global.privateDomain)"
diff --git a/core/installer/values-tmpl/url-shortener.cue b/core/installer/values-tmpl/url-shortener.cue
index b95dd7c..8e9a179 100644
--- a/core/installer/values-tmpl/url-shortener.cue
+++ b/core/installer/values-tmpl/url-shortener.cue
@@ -1,12 +1,12 @@
 input: {
-    network: #Network
-    subdomain: string
-	auth: #Auth
+    network: #Network @name(Network)
+    subdomain: string @name(Subdomain)
+	auth: #Auth @name(Authentication)
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
 
-name: "url-shortener"
+name: "URL Shortener"
 namespace: "app-url-shortener"
 readme: "URL shortener application will be installed on \(input.network.name) network and be accessible at https://\(_domain)"
 description: "Provides URL shortening service. Can be configured to be reachable only from private network or publicly."
diff --git a/core/installer/values-tmpl/vaultwarden.cue b/core/installer/values-tmpl/vaultwarden.cue
index eb10479..72c2ec4 100644
--- a/core/installer/values-tmpl/vaultwarden.cue
+++ b/core/installer/values-tmpl/vaultwarden.cue
@@ -1,6 +1,6 @@
 input: {
-	network: #Network
-	subdomain: string
+    network: #Network @name(Network)
+    subdomain: string @name(Subdomain)
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
diff --git a/core/installer/values-tmpl/zot.cue b/core/installer/values-tmpl/zot.cue
index fa54dd8..a8a7766 100644
--- a/core/installer/values-tmpl/zot.cue
+++ b/core/installer/values-tmpl/zot.cue
@@ -3,13 +3,13 @@
 )
 
 input: {
-	network: #Network
-	subdomain: string
+    network: #Network @name(Network)
+    subdomain: string @name(Subdomain)
 }
 
 _domain: "\(input.subdomain).\(input.network.domain)"
 
-name: "zot"
+name: "Zot"
 namespace: "app-zot"
 readme: "OCI-native container image registry, simplified"
 description: "OCI-native container image registry, simplified"
diff --git a/core/installer/welcome/appmanager-tmpl/app.html b/core/installer/welcome/appmanager-tmpl/app.html
index 8c25adf..cab78bf 100644
--- a/core/installer/welcome/appmanager-tmpl/app.html
+++ b/core/installer/welcome/appmanager-tmpl/app.html
@@ -2,73 +2,75 @@
   {{ $readonly := .ReadOnly }}
   {{ $networks := .AvailableNetworks }}
   {{ $data := .Data }}
-  {{ range $name, $schema := .Schema.Fields }}
+  {{ range $f := .Schema.Fields }}
+  {{ $name := $f.Name }}
+  {{ $schema := $f.Schema }}
     {{ if eq $schema.Kind 0 }}
-      <label for="{{ $name }}">
-        <span>{{ $name }}</span>
+      <label {{ if $schema.Advanced }}hidden{{ end }}>
+		  <input type="checkbox" role="swtich" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.checked)" {{ if $readonly }}disabled{{ end }} {{ if index $data $name }}checked{{ end }} />
+          {{ $schema.Name }}
       </label>
-	  <input type="checkbox" role="swtich" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.checked)" {{ if $readonly }}disabled{{ end }} {{ if index $data $name }}checked{{ end }} />
     {{ else if eq $schema.Kind 7 }}
-      <label for="{{ $name }}">
-        <span>{{ $name }}</span>
+      <label {{ if $schema.Advanced }}hidden{{ end }}>
+          {{ $schema.Name }}
+		  <input type="text" name="{{ $name }}" oninput="valueChanged({{ $name }}, parseInt(this.value))" {{ if $readonly }}disabled{{ end }} value="{{ index $data $name }}" />
       </label>
-	  <input type="text" name="{{ $name }}" oninput="valueChanged({{ $name }}, parseInt(this.value))" {{ if $readonly }}disabled{{ end }} value="{{ index $data $name }}" />
     {{ else if eq $schema.Kind 1 }}
-      <label for="{{ $name }}">
-        <span>{{ $name }}</span>
-      </label>
+      <label {{ if $schema.Advanced }}hidden{{ end }}>
+          {{ $schema.Name }}
 	  <input type="text" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} value="{{ index $data $name }}" />
     {{ else if eq $schema.Kind 4 }}
-      <label for="{{ $name }}">
-        <span>{{ $name }}</span>
       </label>
-	  <input type="text" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} value="{{ index $data $name }}" />
+      <label {{ if $schema.Advanced }}hidden{{ end }}>
+          {{ $schema.Name }}
+		  <input type="text" name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} value="{{ index $data $name }}" />
+      </label>
 	{{ else if eq $schema.Kind 3 }}
-      <label for="{{ $name }}">
-        <span>{{ $name }}</span>
+      <label {{ if $schema.Advanced }}hidden{{ end }}>
+          {{ $schema.Name }}
+		  <select name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} >
+			  {{ if not $readonly }}<option disabled selected value>Available networks</option>{{ end }}
+			  {{ range $networks }}
+			  <option {{if eq .Name (index $data $name) }}selected{{ end }}>{{ .Name }}</option>
+			  {{ end }}
+		  </select>
       </label>
-	  <select name="{{ $name }}" oninput="valueChanged({{ $name }}, this.value)" {{ if $readonly }}disabled{{ end }} >
-		{{ if not $readonly }}<option disabled selected value>Available networks</option>{{ end }}
-		{{ range $networks }}
-		  <option {{if eq .Name (index $data $name) }}selected{{ end }}>{{ .Name }}</option>
-		{{ end }}
-	  </select>
 	{{ else if eq $schema.Kind 5 }}
-      <label for="authEnabled">
-        <span>Require authentication</span>
-      </label>
 	  {{ $auth := index $data $name }}
 	  {{ $authEnabled := false }}
 	  {{ $authGroups := "" }}
 	  {{ if and $auth (index $auth "enabled") }}{{ $authEnabled = true }}{{ end }}
 	  {{ if and $auth (index $auth "groups") }}{{ $authGroups = index $auth "groups" }}{{ end }}
-      <input type="checkbox" role="swtich" name="authEnabled" oninput="valueChanged('{{- $name -}}.enabled', this.checked)" {{ if $readonly }}disabled{{ end }} {{ if $authEnabled  }}checked{{ end }} />
-      <label for="authGroups">
-        <span>Authentication Groups</span>
+      <label {{ if $schema.Advanced }}hidden{{ end }}>
+		  <input type="checkbox" role="swtich" name="authEnabled" oninput="valueChanged('{{- $name -}}.enabled', this.checked)" {{ if $readonly }}disabled{{ end }} {{ if $authEnabled  }}checked{{ end }} />
+          <span>Require authentication</span>
       </label>
-      <input type="text" name="authGroups" oninput="valueChanged('{{- $name -}}.groups', this.value)" {{ if $readonly }}disabled{{ end }} value="{{ $authGroups }}" />
+      <label for="authGroups">
+          <span>Authentication groups</span>
+		  <input type="text" name="authGroups" oninput="valueChanged('{{- $name -}}.groups', this.value)" {{ if $readonly }}disabled{{ end }} value="{{ $authGroups }}" />
+      </label>
 	{{ else if eq $schema.Kind 6 }}
  	  {{ $sshKey := index $data $name }}
 	  {{ $public := "" }}
 	  {{ $private := "" }}
 	  {{ if $sshKey }}{{ $public = index $sshKey "public" }}{{ end }}
 	  {{ if $sshKey }}{{ $private = index $sshKey "private" }}{{ end }}
-      <label for="{{ $name }}-public">
-        <span>Public Key</span>
+      <label {{ if $schema.Advanced }}hidden{{ end }}>
+          <span>Public Key</span>
+		  <textarea name="{{ $name }}-public" disabled>{{ $public }}</textarea>
       </label>
-	  <textarea name="{{ $name }}-public" disabled>{{ $public }}</textarea>
-      <label for="{{ $name }}-private">
-        <span>Private Key</span>
+      <label {{ if $schema.Advanced }}hidden{{ end }}>
+          <span>Private Key</span>
+		  <textarea name="{{ $name }}-private" disabled>{{ $private }}</textarea>
       </label>
-	  <textarea name="{{ $name }}-private" disabled>{{ $private }}</textarea>
     {{ end }}
   {{ end }}
 {{ end }}
 
 {{ define "main" }}
 {{ $instance := .Instance }}
-<h1>{{ .App.Icon }}{{ .App.Name }}</h1>
-<pre id="readme"></pre>
+<h1 style="margin-bottom: 20px">{{ .App.Icon }}{{ .App.Name }}</h1>
+<pre id="readme" style="margin-bottom: 50px">{{ .App.Description }}</pre>
 
 {{ $schema := .App.Schema }}
 {{ $networks := .AvailableNetworks }}
@@ -138,7 +140,6 @@
 </style>
 
 <script>
- let readme = "";
  let config = {{ if $instance }}JSON.parse({{ toJson ($instance.InputToValues $schema) }}){{ else }}{}{{ end }};
 
  function setValue(name, value, config) {
@@ -153,24 +154,8 @@
 }
  function valueChanged(name, value) {
 	 setValue(name, value, config);
-     renderReadme();
  }
 
- async function renderReadme() {
-	 const resp = await fetch("/api/app/{{ .App.Name }}/render", {
-         method: "POST",
-         headers: {
-             "Content-Type": "application/json",
-             "Accept": "application/json",
-         },
-         body: JSON.stringify(config),
-     });
-     const app = await resp.json();
-     document.getElementById("readme").innerHTML = app.readme;
- }
-
- {{ if $instance }}renderReadme();{{ end }}
-
  function disableForm() {
      document.querySelectorAll("#config-form input").forEach((i) => i.setAttribute("disabled", ""));
      document.querySelectorAll("#config-form select").forEach((i) => i.setAttribute("disabled", ""));
@@ -223,7 +208,7 @@
      actionFinished(document.getElementById("toast-uninstall-failure"));
  }
 
- const submitAddr = {{ if $instance }}"/api/instance/{{ $instance.Id }}/update"{{ else }}"/api/app/{{ .App.Name }}/install"{{ end }};
+ const submitAddr = {{ if $instance }}"/api/instance/{{ $instance.Id }}/update"{{ else }}"/api/app/{{ .App.Slug }}/install"{{ end }};
 
  async function install() {
      installStarted();
diff --git a/core/installer/welcome/appmanager.go b/core/installer/welcome/appmanager.go
index e8c929d..a6eb243 100644
--- a/core/installer/welcome/appmanager.go
+++ b/core/installer/welcome/appmanager.go
@@ -52,7 +52,6 @@
 	e := echo.New()
 	e.StaticFS("/static", echo.MustSubFS(staticAssets, "static"))
 	e.GET("/api/app-repo", s.handleAppRepo)
-	e.POST("/api/app/:slug/render", s.handleAppRender)
 	e.POST("/api/app/:slug/install", s.handleAppInstall)
 	e.GET("/api/app/:slug", s.handleApp)
 	e.GET("/api/instance/:slug", s.handleInstance)
@@ -80,7 +79,7 @@
 	}
 	resp := make([]app, len(all))
 	for i, a := range all {
-		resp[i] = app{a.Name(), a.Icon(), a.Description(), a.Name(), nil}
+		resp[i] = app{a.Name(), a.Icon(), a.Description(), a.Slug(), nil}
 	}
 	return c.JSON(http.StatusOK, resp)
 }
@@ -95,7 +94,7 @@
 	if err != nil {
 		return err
 	}
-	return c.JSON(http.StatusOK, app{a.Name(), a.Icon(), a.Description(), a.Name(), instances})
+	return c.JSON(http.StatusOK, app{a.Name(), a.Icon(), a.Description(), a.Slug(), instances})
 }
 
 func (s *AppManagerServer) handleInstance(c echo.Context) error {
@@ -108,50 +107,7 @@
 	if err != nil {
 		return err
 	}
-	return c.JSON(http.StatusOK, app{a.Name(), a.Icon(), a.Description(), a.Name(), []installer.AppInstanceConfig{instance}})
-}
-
-type file struct {
-	Name     string `json:"name"`
-	Contents string `json:"contents"`
-}
-
-type rendered struct {
-	Readme string `json:"readme"`
-}
-
-func (s *AppManagerServer) handleAppRender(c echo.Context) error {
-	slug := c.Param("slug")
-	contents, err := ioutil.ReadAll(c.Request().Body)
-	if err != nil {
-		return err
-	}
-	env, err := s.m.Config()
-	if err != nil {
-		return err
-	}
-	var values map[string]any
-	if err := json.Unmarshal(contents, &values); err != nil {
-		return err
-	}
-	a, err := installer.FindEnvApp(s.r, slug)
-	if err != nil {
-		return err
-	}
-	r, err := a.Render(installer.Release{}, env, values)
-	if err != nil {
-		return err
-	}
-	var resp rendered
-	resp.Readme = r.Readme
-	out, err := json.Marshal(resp)
-	if err != nil {
-		return err
-	}
-	if _, err := c.Response().Writer.Write(out); err != nil {
-		return err
-	}
-	return nil
+	return c.JSON(http.StatusOK, app{a.Name(), a.Icon(), a.Description(), a.Slug(), []installer.AppInstanceConfig{instance}})
 }
 
 func (s *AppManagerServer) handleAppInstall(c echo.Context) error {
@@ -180,7 +136,7 @@
 	if err != nil {
 		return err
 	}
-	instanceId := a.Name() + suffix
+	instanceId := a.Slug() + suffix
 	appDir := fmt.Sprintf("/apps/%s", instanceId)
 	namespace := fmt.Sprintf("%s%s%s", env.NamespacePrefix, a.Namespace(), suffix)
 	if err := s.m.Install(a, instanceId, appDir, namespace, values); err != nil {
@@ -240,7 +196,7 @@
 	}
 	resp := make([]app, len(all))
 	for i, a := range all {
-		resp[i] = app{a.Name(), a.Icon(), a.Description(), a.Name(), nil}
+		resp[i] = app{a.Name(), a.Icon(), a.Description(), a.Slug(), nil}
 	}
 	return tmpl.Execute(c.Response(), resp)
 }
@@ -305,7 +261,7 @@
 	if err != nil {
 		return err
 	}
-	instances, err := s.m.FindAllAppInstances(a.Name())
+	instances, err := s.m.FindAllAppInstances(a.Slug())
 	if err != nil {
 		return err
 	}
diff --git a/core/installer/welcome/welcome.go b/core/installer/welcome/welcome.go
index c5d732c..d7bccdd 100644
--- a/core/installer/welcome/welcome.go
+++ b/core/installer/welcome/welcome.go
@@ -223,7 +223,7 @@
 				http.Error(w, err.Error(), http.StatusInternalServerError)
 				return
 			}
-			instanceId := fmt.Sprintf("%s-%s", app.Name(), req.Username)
+			instanceId := fmt.Sprintf("%s-%s", app.Slug(), req.Username)
 			appDir := fmt.Sprintf("/apps/%s", instanceId)
 			namespace := fmt.Sprintf("%s%s", env.NamespacePrefix, app.Namespace())
 			if err := appManager.Install(app, instanceId, appDir, namespace, map[string]any{