Add app detail/info pages with optional config support
- New route: GET /apps/{slug} renders info page with use cases, setup guide, prerequisites
- New API: POST /api/stacks/{name}/optional-config for updating optional env vars
- New structs: AppInfo, OptionalConfigGroup, OptionalConfigField in metadata.go
- UpdateOptionalConfig saves to app.yaml and restarts deployed stacks with new env vars
- Info page template with hero section, screenshots, info cards, optional config form
- Navigation: stack cards now link to /apps/{slug}, deploy page has "Részletek" link
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user