# TASK.md — Current Task: App Detail/Info Pages
> Read CLAUDE.md first for project context, workspace layout, and build instructions.
> This file describes the current task to implement.
## Overview
Add a dedicated app information page to the felhom-controller dashboard. This page shows detailed
info about each app (use cases, setup guide, prerequisites) and allows configuring **optional
settings** (like API keys for metadata providers) both before and after deployment.
The first app to get this treatment is **RoMM** (retro game ROM manager), which has optional
metadata provider API keys (IGDB, SteamGridDB, ScreenScraper, MobyGames).
## Architecture context
- All HTML/CSS is embedded as Go string constants in `internal/web/templates.go`
- All UI text is in **Hungarian**
- Routes are defined in `internal/web/server.go` `ServeHTTP()` method (manual path matching)
- API routes in `internal/api/router.go` `ServeHTTP()` method
- App metadata lives in `.felhom.yml` files parsed by `internal/stacks/metadata.go`
- Per-app deployed config is saved in `app.yaml` (managed by `internal/stacks/deploy.go`)
- Template functions registered in `server.go` `initTemplates()`
## Changes needed
This task touches **4 Go files** in the controller + **2 files** in the app-catalog repo.
---
### 1. `controller/internal/stacks/metadata.go` — Add new structs
Add these new fields to the existing `Metadata` struct, and add the new supporting structs:
```go
// Extend the existing Metadata struct with these new fields:
type Metadata struct {
// ... all existing fields stay unchanged ...
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"`
// NEW fields — add these:
AppInfo AppInfo `yaml:"app_info" json:"app_info"`
OptionalConfig []OptionalConfigGroup `yaml:"optional_config" json:"optional_config"`
}
// NEW: 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"`
}
// NEW: Optional config group (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"`
}
// NEW: 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"` // "text" or "secret_input"
HelpURL string `yaml:"help_url" json:"help_url"`
HelpText string `yaml:"help_text" json:"help_text"`
}
```
Add helper methods:
```go
// 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
}
```
The existing `LoadMetadata()` function uses `yaml.Unmarshal` and will automatically populate the
new fields if they exist in the YAML. No changes needed to `LoadMetadata()`.
---
### 2. `controller/internal/stacks/deploy.go` — Add optional config update method
Add a new method for updating optional env vars in `app.yaml`. This is safe to call on deployed
apps — it only touches env vars listed in `optional_config`, never locked fields.
```go
// 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 {
m.mu.Lock()
defer m.mu.Unlock()
stack, ok := m.stacks[stackName]
if !ok {
return fmt.Errorf("stack not found: %s", 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)
appCfgPath := filepath.Join(m.cfg.Paths.StacksDir, stackName, "app.yaml")
var appCfg AppConfig
if data, err := os.ReadFile(appCfgPath); err == nil {
_ = yaml.Unmarshal(data, &appCfg)
}
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
data, err := yaml.Marshal(&appCfg)
if err != nil {
return fmt.Errorf("marshal app.yaml: %w", err)
}
if err := os.WriteFile(appCfgPath, data, 0600); err != nil {
return fmt.Errorf("write app.yaml: %w", err)
}
m.logger.Printf("[INFO] Saved updated app.yaml for %s", stackName)
// If deployed, write .env and restart to pick up new env vars
if stack.Deployed {
// Write .env file — reuse the same pattern used by DeployStack
// Look at how DeployStack writes the .env file and follow the same approach
if err := m.writeEnvFile(stackName, appCfg.Env); err != nil {
return fmt.Errorf("write .env: %w", err)
}
m.logger.Printf("[INFO] Restarting %s to apply new optional config", stackName)
// Need to call the internal restart logic (without re-acquiring the lock)
// Check if there's an unlocked restart method; if not, create one by factoring
// the restart logic out of RestartStack into a restartStackLocked helper
if err := m.restartStackLocked(stackName); err != nil {
return fmt.Errorf("restart after config update: %w", err)
}
}
return nil
}
```
**IMPORTANT implementation notes:**
- Check how `DeployStack` currently writes `.env` files — there's likely a helper that iterates
`appCfg.Env` and writes `KEY=VALUE` lines. Reuse that exact logic.
- The existing `RestartStack()` acquires the mutex lock. Since `UpdateOptionalConfig` already
holds the lock, you need an internal unlocked version. Either:
- Factor the restart logic out of `RestartStack` into `restartStackLocked` (recommended), or
- Temporarily release and re-acquire the lock (less clean, avoid if possible)
- Look at `manager.go` for the restart implementation to understand what needs to be factored out.
Also add a public method to load app config (if not already public):
```go
// LoadAppConfig reads app.yaml from a stack directory. Returns nil if not found.
func (m *Manager) LoadAppConfig(stackName string) (*AppConfig, error) {
appCfgPath := filepath.Join(m.cfg.Paths.StacksDir, stackName, "app.yaml")
data, err := os.ReadFile(appCfgPath)
if err != nil {
return nil, err
}
var cfg AppConfig
if err := yaml.Unmarshal(data, &cfg); err != nil {
return nil, err
}
return &cfg, nil
}
```
---
### 3. `controller/internal/api/router.go` — Add optional config API endpoint
Add a new route case in the `ServeHTTP` switch block:
```go
// POST /api/stacks/{name}/optional-config
case hasSuffix(path, "/optional-config") && req.Method == http.MethodPost:
r.updateOptionalConfig(w, req, extractName(path, "/optional-config"))
```
And the handler function:
```go
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"})
}
```
---
### 4. `controller/internal/web/server.go` — Add info page route and handler
#### 4a. The route already exists — update it
There's already a case in `ServeHTTP` for `/apps/{slug}`. Currently it redirects to the deploy
page. Replace the `appDetailHandler` method with a real page handler:
```go
func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
var found *stacks.StackInfo
for _, stack := range s.stackMgr.GetStacks() {
if stack.Meta.Slug == slug {
found = &stack
break
}
}
if found == nil {
http.NotFound(w, r)
return
}
// Load current optional config values from app.yaml
currentValues := make(map[string]string)
if appCfg, err := s.stackMgr.LoadAppConfig(found.Name); err == nil && 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)
}
```
#### 4b. Add template functions
In `initTemplates()`, add to the `funcMap`:
```go
"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
},
```
---
### 5. `controller/internal/web/templates.go` — Add info page template + CSS
#### 5a. Update `allTemplates` const
```go
const allTemplates = layoutTmpl + dashboardTmpl + stacksTmpl + loginTmpl + logsTmpl + deployTmpl + appInfoTmpl
```
#### 5b. Add `appInfoTmpl` const
Design goals: clean layout matching existing dark theme, Hungarian UI, sections for hero header,
app info cards, screenshots, and optional config form with AJAX save.
```go
const appInfoTmpl = `
{{define "app_info"}}
{{template "layout_start" .}}