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 = `
{{.AppInfo.Tagline}}
+ {{else}} +{{.Meta.Description}}
+ {{end}} + +{{.AppInfo.DefaultCreds}}
+Az első bejelentkezés után azonnal változtasd meg!
+{{.Description}}
{{end}} + +{{.HelpText}}
{{end}} + {{if .HelpURL}}{{end}} + +