diff --git a/controller/internal/api/router.go b/controller/internal/api/router.go index 66443dc..58c084a 100644 --- a/controller/internal/api/router.go +++ b/controller/internal/api/router.go @@ -75,6 +75,10 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) { case hasSuffix(path, "/update") && req.Method == http.MethodPost: r.actionStack(w, "update", extractName(path, "/update")) + // POST /api/stacks/{name}/optional-config + case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost: + r.updateOptionalConfig(w, req, extractName(path, "/optional-config")) + // GET /api/stacks/{name}/logs case hasSuffix(path, "/logs") && req.Method == http.MethodGet: r.getStackLogs(w, req, extractName(path, "/logs")) @@ -210,6 +214,26 @@ func (r *Router) actionStack(w http.ResponseWriter, action, name string) { writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"}) } +func (r *Router) updateOptionalConfig(w http.ResponseWriter, req *http.Request, name string) { + r.logger.Printf("[API] Optional config update requested for stack: %s", name) + + var body struct { + Values map[string]string `json:"values"` + } + if err := json.NewDecoder(req.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, apiResponse{OK: false, Error: "invalid request body"}) + return + } + + if err := r.stackMgr.UpdateOptionalConfig(name, body.Values); err != nil { + r.logger.Printf("[API] Optional config update failed for %s: %v", name, err) + writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()}) + return + } + + writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Beállítások frissítve"}) +} + func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name string) { lines := 100 if v := req.URL.Query().Get("lines"); v != "" { diff --git a/controller/internal/stacks/deploy.go b/controller/internal/stacks/deploy.go index f0d3167..e59b1f0 100644 --- a/controller/internal/stacks/deploy.go +++ b/controller/internal/stacks/deploy.go @@ -282,6 +282,85 @@ func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) { return &meta, appCfg, nil } +// UpdateOptionalConfig updates optional env vars in app.yaml and restarts the stack if deployed. +// Only updates env vars that are listed in the metadata's optional_config sections. +func (m *Manager) UpdateOptionalConfig(stackName string, values map[string]string) error { + stack, ok := m.GetStack(stackName) + if !ok { + return fmt.Errorf("stack %q not found", stackName) + } + + // Build a set of allowed env vars from optional_config + allowed := make(map[string]bool) + for _, group := range stack.Meta.OptionalConfig { + for _, field := range group.Fields { + allowed[field.EnvVar] = true + } + } + if len(allowed) == 0 { + return fmt.Errorf("no optional config fields defined for %s", stackName) + } + + // Load existing app.yaml (or create empty one) + stackDir := filepath.Dir(stack.ComposePath) + appCfg := LoadAppConfig(stackDir) + if appCfg == nil { + appCfg = &AppConfig{ + Env: make(map[string]string), + } + } + if appCfg.Env == nil { + appCfg.Env = make(map[string]string) + } + + // Update only allowed env vars + changed := false + for key, val := range values { + if !allowed[key] { + m.logger.Printf("[WARN] Ignoring non-optional env var: %s", key) + continue + } + if appCfg.Env[key] != val { + appCfg.Env[key] = val + changed = true + m.logger.Printf("[INFO] Updated optional config %s for %s", key, stackName) + } + } + + if !changed { + return nil + } + + // Save app.yaml + if err := SaveAppConfig(stackDir, appCfg); err != nil { + return fmt.Errorf("saving app config: %w", err) + } + m.logger.Printf("[INFO] Saved updated app.yaml for %s", stackName) + + // If deployed, recreate containers to pick up new env vars + // (docker compose restart does NOT pick up new env vars — must use up -d) + if stack.Deployed { + m.logger.Printf("[INFO] Restarting %s to apply new optional config", stackName) + env := m.stackEnv(stackDir) + if _, err := m.composeExecCustomEnv(stackDir, env, "up", "-d"); err != nil { + return fmt.Errorf("restart after config update: %w", err) + } + m.logPostStartStatus(stackName, stackDir, env) + } + + return m.RefreshStatus() +} + +// LoadAppConfigByName reads app.yaml for a named stack. Returns nil if not found. +func (m *Manager) LoadAppConfigByName(stackName string) *AppConfig { + stack, ok := m.GetStack(stackName) + if !ok { + return nil + } + stackDir := filepath.Dir(stack.ComposePath) + return LoadAppConfig(stackDir) +} + // --- App config persistence --- func LoadAppConfig(stackDir string) *AppConfig { diff --git a/controller/internal/stacks/metadata.go b/controller/internal/stacks/metadata.go index 56c840a..49734d0 100644 --- a/controller/internal/stacks/metadata.go +++ b/controller/internal/stacks/metadata.go @@ -10,13 +10,41 @@ import ( // Metadata holds app information parsed from .felhom.yml. type Metadata struct { - DisplayName string `yaml:"display_name" json:"display_name"` - Description string `yaml:"description" json:"description"` - Category string `yaml:"category" json:"category"` - Subdomain string `yaml:"subdomain" json:"subdomain"` - Slug string `yaml:"slug" json:"slug"` - Resources ResourceHints `yaml:"resources" json:"resources"` - DeployFields []DeployField `yaml:"deploy_fields" json:"deploy_fields"` + DisplayName string `yaml:"display_name" json:"display_name"` + Description string `yaml:"description" json:"description"` + Category string `yaml:"category" json:"category"` + Subdomain string `yaml:"subdomain" json:"subdomain"` + Slug string `yaml:"slug" json:"slug"` + Resources ResourceHints `yaml:"resources" json:"resources"` + DeployFields []DeployField `yaml:"deploy_fields" json:"deploy_fields"` + AppInfo AppInfo `yaml:"app_info" json:"app_info"` + OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"` +} + +// AppInfo holds detailed app information for the info page. +type AppInfo struct { + Tagline string `yaml:"tagline" json:"tagline"` + UseCases []string `yaml:"use_cases" json:"use_cases"` + FirstSteps []string `yaml:"first_steps" json:"first_steps"` + Prerequisites []string `yaml:"prerequisites" json:"prerequisites"` + DefaultCreds string `yaml:"default_creds" json:"default_creds"` + DocsURL string `yaml:"docs_url" json:"docs_url"` +} + +// OptionalConfigGroup defines a group of optional config fields (e.g., "Metadata providers"). +type OptionalConfigGroup struct { + Group string `yaml:"group" json:"group"` + Description string `yaml:"description" json:"description"` + Fields []OptionalConfigField `yaml:"fields" json:"fields"` +} + +// OptionalConfigField defines an individual optional config field. +type OptionalConfigField struct { + EnvVar string `yaml:"env_var" json:"env_var"` + Label string `yaml:"label" json:"label"` + Type string `yaml:"type" json:"type"` + HelpURL string `yaml:"help_url" json:"help_url"` + HelpText string `yaml:"help_text" json:"help_text"` } // ResourceHints describe what the app needs. @@ -131,3 +159,13 @@ func (m *Metadata) AutoGeneratedFields() []DeployField { } return fields } + +// HasAppInfo returns true if the metadata has any app info content. +func (m *Metadata) HasAppInfo() bool { + return m.AppInfo.Tagline != "" || len(m.AppInfo.UseCases) > 0 || len(m.AppInfo.FirstSteps) > 0 +} + +// HasOptionalConfig returns true if the metadata has any optional config groups. +func (m *Metadata) HasOptionalConfig() bool { + return len(m.OptionalConfig) > 0 +} diff --git a/controller/internal/web/server.go b/controller/internal/web/server.go index 8c78114..53cf3cb 100644 --- a/controller/internal/web/server.go +++ b/controller/internal/web/server.go @@ -165,6 +165,16 @@ func (s *Server) loadTemplates() { } return r }, + "screenshotURL": func(slug string, index int) string { + return s.cfg.AppScreenshotURL(slug, index) + }, + "seq": func(n int) []int { + result := make([]int, n) + for i := range result { + result[i] = i + 1 + } + return result + }, } s.tmpl = template.Must(template.New("").Funcs(funcMap).Parse(allTemplates)) @@ -475,16 +485,37 @@ func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename str http.ServeFile(w, r, path) } -func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) { +func (s *Server) appDetailHandler(w http.ResponseWriter, _ *http.Request, slug string) { + var found *stacks.Stack for _, stack := range s.stackMgr.GetStacks() { if stack.Meta.Slug == slug { - // Always redirect to deploy page — it has a read-only mode for - // deployed apps showing current settings, plus the external link - http.Redirect(w, r, "/stacks/"+stack.Name+"/deploy", http.StatusFound) - return + found = &stack + break } } - http.NotFound(w, r) + if found == nil { + http.NotFound(w, nil) + return + } + + // Load current optional config values from app.yaml + currentValues := make(map[string]string) + if appCfg := s.stackMgr.LoadAppConfigByName(found.Name); appCfg != nil { + for k, v := range appCfg.Env { + currentValues[k] = v + } + } + + data := s.baseData("stacks", found.Meta.DisplayName) + data["Stack"] = found + data["Meta"] = found.Meta + data["AppInfo"] = found.Meta.AppInfo + data["OptionalConfig"] = found.Meta.OptionalConfig + data["CurrentValues"] = currentValues + data["HasAppInfo"] = found.Meta.HasAppInfo() + data["HasOptionalConfig"] = found.Meta.HasOptionalConfig() + + s.render(w, "app_info", data) } func (s *Server) renderLogin(w http.ResponseWriter, errorMsg string) { diff --git a/controller/internal/web/templates.go b/controller/internal/web/templates.go index e4854b9..c0aebe2 100644 --- a/controller/internal/web/templates.go +++ b/controller/internal/web/templates.go @@ -4,7 +4,7 @@ package web // Compiled into the binary — zero external file dependencies at runtime. // As the UI grows, switch to go:embed for easier editing. -const allTemplates = layoutTmpl + dashboardTmpl + stacksTmpl + loginTmpl + logsTmpl + deployTmpl +const allTemplates = layoutTmpl + dashboardTmpl + stacksTmpl + loginTmpl + logsTmpl + deployTmpl + appInfoTmpl const layoutTmpl = ` {{define "layout_start"}} @@ -187,7 +187,7 @@ const dashboardTmpl = `
{{range .Stacks}} -
+
@@ -238,7 +238,7 @@ const stacksTmpl = `
{{range .Stacks}} -
+
- ← Vissza -

{{.Meta.DisplayName}} — Telepítés

+
+ ← Vissza +

{{.Meta.DisplayName}} — Telepítés

+
+ ℹ️ Részletek
@@ -322,7 +325,7 @@ const deployTmpl = ` {{if .Meta.Resources.PiCompatible}}Pi kompatibilis{{end}} {{if .Meta.Resources.NeedsHDD}}HDD szükséges{{end}}
- + Részletes leírás, képernyőképek
@@ -738,6 +741,186 @@ const logsTmpl = ` {{end}} ` +const appInfoTmpl = ` +{{define "app_info"}} +{{template "layout_start" .}} + + + + +
+ +
+ {{if .AppInfo.Tagline}} +

{{.AppInfo.Tagline}}

+ {{else}} +

{{.Meta.Description}}

+ {{end}} +
+ ~{{.Meta.Resources.MemRequest}} RAM + {{.Meta.Category}} + {{if .Meta.Resources.NeedsHDD}}HDD szükséges{{end}} + {{if .Meta.Resources.PiCompatible}}Pi kompatibilis{{else}}Csak x86{{end}} +
+
+
+ + +
+ + + +
+ +{{if .HasAppInfo}} +
+ {{if .AppInfo.UseCases}} +
+

Mire használható?

+
    + {{range .AppInfo.UseCases}}
  • {{.}}
  • {{end}} +
+
+ {{end}} + + {{if .AppInfo.FirstSteps}} +
+

Első lépések

+
    + {{range .AppInfo.FirstSteps}}
  1. {{.}}
  2. {{end}} +
+
+ {{end}} + + {{if .AppInfo.Prerequisites}} +
+

Előfeltételek

+
    + {{range .AppInfo.Prerequisites}}
  • {{.}}
  • {{end}} +
+
+ {{end}} + + {{if .AppInfo.DefaultCreds}} +
+

Alapértelmezett belépés

+

{{.AppInfo.DefaultCreds}}

+

Az első bejelentkezés után azonnal változtasd meg!

+
+ {{end}} + + {{if .AppInfo.DocsURL}} +
+

Dokumentáció

+

Hivatalos dokumentáció ↗

+
+ {{end}} +
+{{end}} + +{{if .HasOptionalConfig}} +
+

Opcionális beállítások

+ {{range .OptionalConfig}} +
+

{{.Group}}

+ {{if .Description}}

{{.Description}}

{{end}} + +
+ {{range .Fields}} +
+ + {{if .HelpText}}

{{.HelpText}}

{{end}} + {{if .HelpURL}}

Regisztrációs útmutató ↗

{{end}} + +
+ {{end}} +
+
+ {{end}} + +
+ + +
+
+ + +{{end}} + +{{template "layout_end" .}} +{{end}} +` + // CSS is defined in a separate const for readability. // Served at /static/style.css const cssContent = ` @@ -1622,6 +1805,176 @@ select.form-control option { background: var(--bg-secondary); color: var(--text- } .login-footer a:hover { color: var(--accent-blue); } +/* --- App Info Page --- */ +.app-info-hero { + display: flex; + align-items: center; + gap: 1.5rem; + padding: 1.5rem; + background: var(--bg-card); + border-radius: var(--radius); + border: 1px solid var(--border-color); + margin-bottom: 1.5rem; +} +.app-info-logo { + width: 80px; + height: 80px; + border-radius: 12px; + object-fit: contain; + background: var(--bg-secondary); + padding: 10px; + flex-shrink: 0; +} +.app-info-tagline { + font-size: 1.1rem; + color: var(--text-primary); + margin: 0 0 .75rem 0; +} +.app-screenshots { + display: flex; + gap: 1rem; + margin-bottom: 1.5rem; + overflow-x: auto; + padding-bottom: .5rem; +} +.app-screenshot { + max-height: 220px; + border-radius: var(--radius); + border: 1px solid var(--border-color); + object-fit: cover; +} +.app-info-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: 1rem; + margin-bottom: 1.5rem; +} +.app-info-card { + background: var(--bg-card); + border-radius: var(--radius); + padding: 1.25rem; + border: 1px solid var(--border-color); +} +.app-info-card h3 { + margin: 0 0 .75rem 0; + font-size: .95rem; + color: var(--text-primary); +} +.app-info-list { + margin: 0; + padding-left: 1.25rem; + color: var(--text-secondary); + font-size: .9rem; + line-height: 1.6; +} +.app-info-creds { + font-family: monospace; + font-size: 1rem; + color: var(--accent-light); + background: var(--bg-secondary); + padding: .5rem .75rem; + border-radius: 4px; + display: inline-block; + margin: 0 0 .5rem 0; +} +.app-info-creds-warn { + color: var(--orange); + font-size: .85rem; + margin: 0; +} +.app-info-link { + color: var(--accent-light); + text-decoration: none; +} +.app-info-link:hover { text-decoration: underline; } + +/* Optional config section */ +.app-optional-config { + background: var(--bg-card); + border-radius: var(--radius); + padding: 1.5rem; + border: 1px solid var(--border-color); + margin-bottom: 1.5rem; +} +.app-optional-config h3 { + margin: 0 0 1rem 0; + font-size: 1.05rem; +} +.config-group { + margin-bottom: 1.5rem; +} +.config-group h4 { + margin: 0 0 .5rem 0; + font-size: .95rem; + color: var(--text-primary); +} +.config-group-desc { + color: var(--text-secondary); + font-size: .85rem; + margin: 0 0 1rem 0; +} +.config-fields { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 1rem; +} +.config-field { + display: flex; + flex-direction: column; + gap: .25rem; +} +.config-field label { + font-size: .85rem; + font-weight: 500; + color: var(--text-primary); +} +.config-field-help { + font-size: .8rem; + color: var(--text-secondary); + margin: 0; + line-height: 1.4; +} +.config-field-help a { + color: var(--accent-light); + text-decoration: none; +} +.config-field-help a:hover { text-decoration: underline; } +.config-input { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: .5rem .75rem; + color: var(--text-primary); + font-size: .9rem; + font-family: monospace; + width: 100%; + box-sizing: border-box; +} +.config-input:focus { + outline: none; + border-color: var(--accent-blue); +} +.config-actions { + display: flex; + align-items: center; + gap: 1rem; + margin-top: 1rem; +} +.config-save-status { + font-size: .9rem; + transition: opacity .3s; +} +.config-save-ok { color: var(--green); } +.config-save-err { color: var(--red); } +.meta-badge-warn { + background: rgba(255, 152, 0, 0.1) !important; + color: var(--orange) !important; +} +.meta-badge-ok { + background: rgba(76, 175, 80, 0.1) !important; + color: var(--green) !important; +} + /* Responsive */ @media(max-width: 768px) { .sidebar { width: 100%; height: auto; position: relative; border-right: none; border-bottom: 1px solid var(--border-color); }