Files
deploy-felhom-compose/TASK.md
T
2026-02-14 20:00:24 +01:00

32 KiB
Raw Blame History

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:

// 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:

// 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.

// 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):

// 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:

// 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:

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:

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:

"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

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.

const appInfoTmpl = `
{{define "app_info"}}
{{template "layout_start" .}}

<div class="page-header">
    <div style="display:flex;align-items:center;gap:1rem">
        <a href="/stacks" class="btn btn-sm btn-outline">← Alkalmazások</a>
        <h2>{{.Meta.DisplayName}}</h2>
    </div>
    <div style="display:flex;align-items:center;gap:.5rem">
        {{if .Stack.Deployed}}
            <span class="stack-state-badge state-{{stateColor .Stack.State}}">{{stateLabel .Stack.State}}</span>
            <a href="https://{{.Meta.Subdomain}}.{{.Domain}}" target="_blank" class="btn btn-sm btn-outline">Megnyitás ↗</a>
            <a href="/stacks/{{.Stack.Name}}/logs" class="btn btn-sm btn-outline">Napló</a>
            <a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-outline">Beállítások</a>
        {{else}}
            <a href="/stacks/{{.Stack.Name}}/deploy" class="btn btn-sm btn-primary" onclick="return checkBeforeDeploy(event, '{{.Stack.Name}}')">Telepítés</a>
        {{end}}
    </div>
</div>

<!-- Hero section -->
<div class="app-info-hero">
    <img class="app-info-logo" src="{{logoURL .Meta.Slug}}"
         alt="{{.Meta.DisplayName}}"
         onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
    <div class="app-info-hero-text">
        {{if .AppInfo.Tagline}}
            <p class="app-info-tagline">{{.AppInfo.Tagline}}</p>
        {{else}}
            <p class="app-info-tagline">{{.Meta.Description}}</p>
        {{end}}
        <div class="stack-meta-badges">
            <span class="meta-badge">~{{.Meta.Resources.MemRequest}} RAM</span>
            <span class="meta-badge">{{.Meta.Category}}</span>
            {{if .Meta.Resources.NeedsHDD}}<span class="meta-badge meta-badge-warn">HDD szükséges</span>{{end}}
            {{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">Pi kompatibilis</span>{{else}}<span class="meta-badge meta-badge-warn">Csak x86</span>{{end}}
        </div>
    </div>
</div>

<!-- Screenshots (graceful — hidden if assets don't exist) -->
<div class="app-screenshots" id="screenshots">
    <img src="{{screenshotURL .Meta.Slug 1}}" alt="" class="app-screenshot"
         onerror="this.style.display='none'">
    <img src="{{screenshotURL .Meta.Slug 2}}" alt="" class="app-screenshot"
         onerror="this.style.display='none'">
    <img src="{{screenshotURL .Meta.Slug 3}}" alt="" class="app-screenshot"
         onerror="this.style.display='none'">
</div>

{{if .HasAppInfo}}
<div class="app-info-grid">
    {{if .AppInfo.UseCases}}
    <div class="app-info-card">
        <h3>🎯 Mire használható?</h3>
        <ul class="app-info-list">
            {{range .AppInfo.UseCases}}<li>{{.}}</li>{{end}}
        </ul>
    </div>
    {{end}}

    {{if .AppInfo.FirstSteps}}
    <div class="app-info-card">
        <h3>🚀 Első lépések</h3>
        <ol class="app-info-list">
            {{range .AppInfo.FirstSteps}}<li>{{.}}</li>{{end}}
        </ol>
    </div>
    {{end}}

    {{if .AppInfo.Prerequisites}}
    <div class="app-info-card">
        <h3>📋 Előfeltételek</h3>
        <ul class="app-info-list">
            {{range .AppInfo.Prerequisites}}<li>{{.}}</li>{{end}}
        </ul>
    </div>
    {{end}}

    {{if .AppInfo.DefaultCreds}}
    <div class="app-info-card">
        <h3>🔑 Alapértelmezett belépés</h3>
        <p class="app-info-creds">{{.AppInfo.DefaultCreds}}</p>
        <p class="app-info-creds-warn">⚠️ Az első bejelentkezés után azonnal változtasd meg!</p>
    </div>
    {{end}}

    {{if .AppInfo.DocsURL}}
    <div class="app-info-card">
        <h3>📖 Dokumentáció</h3>
        <p><a href="{{.AppInfo.DocsURL}}" target="_blank" class="app-info-link">Hivatalos dokumentáció ↗</a></p>
    </div>
    {{end}}
</div>
{{end}}

{{if .HasOptionalConfig}}
<div class="app-optional-config">
    <h3>⚙️ Opcionális beállítások</h3>
    {{range .OptionalConfig}}
    <div class="config-group">
        <h4>{{.Group}}</h4>
        {{if .Description}}<p class="config-group-desc">{{.Description}}</p>{{end}}

        <div class="config-fields">
            {{range .Fields}}
            <div class="config-field">
                <label for="opt-{{.EnvVar}}">{{.Label}}</label>
                {{if .HelpText}}<p class="config-field-help">{{.HelpText}}</p>{{end}}
                {{if .HelpURL}}<p class="config-field-help"><a href="{{.HelpURL}}" target="_blank">Regisztrációs útmutató ↗</a></p>{{end}}
                <input type="{{if eq .Type "secret_input"}}password{{else}}text{{end}}"
                       id="opt-{{.EnvVar}}"
                       name="{{.EnvVar}}"
                       class="config-input"
                       value="{{index $.CurrentValues .EnvVar}}"
                       placeholder="{{.Label}}"
                       autocomplete="off">
            </div>
            {{end}}
        </div>
    </div>
    {{end}}

    <div class="config-actions">
        <button class="btn btn-primary" id="save-optional-config" onclick="saveOptionalConfig('{{.Stack.Name}}')">
            Mentés
        </button>
        <span id="config-save-status" class="config-save-status"></span>
    </div>
</div>

<script>
async function saveOptionalConfig(stackName) {
    const btn = document.getElementById('save-optional-config');
    const status = document.getElementById('config-save-status');
    const inputs = document.querySelectorAll('.config-input');

    const values = {};
    inputs.forEach(function(input) {
        values[input.name] = input.value;
    });

    btn.disabled = true;
    btn.textContent = 'Mentés...';
    status.textContent = '';
    status.className = 'config-save-status';

    try {
        const resp = await fetch('/api/stacks/' + stackName + '/optional-config', {
            method: 'POST',
            headers: {'Content-Type': 'application/json'},
            body: JSON.stringify({values: values})
        });
        const data = await resp.json();

        if (data.ok) {
            status.textContent = '✓ ' + (data.message || 'Mentve');
            status.className = 'config-save-status config-save-ok';
        } else {
            status.textContent = '✗ ' + (data.error || 'Hiba');
            status.className = 'config-save-status config-save-err';
        }
    } catch(err) {
        status.textContent = '✗ Hálózati hiba';
        status.className = 'config-save-status config-save-err';
    }

    btn.disabled = false;
    btn.textContent = 'Mentés';

    setTimeout(function() { status.textContent = ''; }, 5000);
}
</script>
{{end}}

{{template "layout_end" .}}
{{end}}
`

5c. Add CSS to cssContent

Append these styles at the end of cssContent (before the closing backtick):

/* --- 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;
}

In stacksTmpl, the cards currently have data-href="/stacks/{{.Name}}/deploy". Change to:

{{if not .Protected}} data-href="/apps/{{.Meta.Slug}}"{{end}}

6b. Stack cards on Dashboard → same change

Check dashboardTmpl for any data-href attributes pointing to /stacks/.../deploy and update them similarly to link to /apps/{{.Meta.Slug}} instead.

In deployTmpl, add a link back to the info page near the top header area:

<a href="/apps/{{.Meta.Slug}}" class="btn btn-sm btn-outline">️ Részletek</a>

7. RoMM .felhom.yml — Update in app-catalog repo

File: E:\git\app-catalog-felhom.eu\templates\romm\.felhom.yml

Replace the entire file with:

# =============================================================================
# .felhom.yml — App metadata for felhom-controller
# =============================================================================

# --- Display info (shown on dashboard) ---
display_name: "RomM"
description: "Retró játékgyűjtemény kezelő"
category: "media"
subdomain: "arcade"
slug: "romm"

# --- Resource hints (displayed on deploy screen) ---
resources:
  mem_request: "300M"
  mem_limit: "1024M"
  pi_compatible: false
  needs_hdd: true

# --- Deploy fields (first deployment only) ---
deploy_fields:
  - env_var: DOMAIN
    label: "Domain"
    type: domain
    description: "A szerver domain neve"
    locked_after_deploy: true

  - env_var: DB_PASSWORD
    label: "Adatbázis jelszó"
    type: secret
    generate: "password:24"
    locked_after_deploy: true

  - env_var: MYSQL_ROOT_PASSWORD
    label: "MariaDB root jelszó"
    type: secret
    generate: "password:24"
    locked_after_deploy: true

  - env_var: ROMM_AUTH_SECRET_KEY
    label: "Hitelesítési titkosítási kulcs"
    type: secret
    generate: "hex:32"
    locked_after_deploy: true

  - env_var: HDD_PATH
    label: "Adattárolási útvonal"
    type: path
    required: true
    placeholder: "/mnt/hdd_1"
    description: "A külső merevlemez elérési útja, ahol a ROM-ok és borítóképek tárolódnak"
    locked_after_deploy: true

# --- App info (info page content) ---
app_info:
  tagline: "Retró játékgyűjtemény kezelő, böngésző és lejátszó"
  default_creds: "admin / admin"
  docs_url: "https://github.com/rommapp/romm/wiki"

  use_cases:
    - "Retró játékgyűjtemény rendszerezése és böngészése webes felületen"
    - "Játékok metaadatainak automatikus letöltése — borítók, leírások, értékelések"
    - "Platformonkénti szűrés és keresés a teljes gyűjteményben"
    - "ROM fájlok webes feltöltése és letöltése"
    - "Többfelhasználós hozzáférés a háztartás tagjai számára"

  first_steps:
    - "Nyisd meg az arcade.DOMAIN címet a böngészőben"
    - "Jelentkezz be az alapértelmezett admin / admin fiókkal"
    - "Változtasd meg azonnal a jelszót a Settings menüben"
    - "Töltsd fel a ROM fájlokat a library mappába (platform/játéknév struktúrával)"
    - "Indíts egy Scan-t a bal oldali menüben a ROM-ok beolvasásához"
    - "Opcionális: állíts be metaadat-szolgáltatókat a borítóképek és leírások automatikus letöltéséhez (lásd lent)"

  prerequisites:
    - "Külső HDD szükséges a ROM fájlok és borítóképek tárolásához"
    - "Legalább 1 GB szabad RAM ajánlott (MariaDB + Redis + RomM)"
    - "ROM fájlok platform mappákba rendezve (pl. library/gba/, library/snes/)"

# --- Optional config (configurable before or after deployment) ---
optional_config:
  - group: "Metaadat-szolgáltatók"
    description: "Játékok borítóinak, leírásainak és értékeléseinek automatikus letöltéséhez. Legalább az IGDB beállítása ajánlott. Mindegyik ingyenesen használható regisztráció után."
    fields:
      - env_var: IGDB_CLIENT_ID
        label: "IGDB Client ID"
        type: text
        help_url: "https://api-docs.igdb.com/#getting-started"
        help_text: "1) Regisztrálj / jelentkezz be a Twitch fejlesztői portálon (dev.twitch.tv). 2) Hozz létre egy új alkalmazást (bármilyen névvel). 3) Másold be a Client ID-t."

      - env_var: IGDB_CLIENT_SECRET
        label: "IGDB Client Secret"
        type: secret_input
        help_text: "A Twitch alkalmazásod Client Secret-je (a „New Secret" gombbal generálhatod)."

      - env_var: STEAMGRIDDB_API_KEY
        label: "SteamGridDB API Key"
        type: text
        help_url: "https://www.steamgriddb.com/profile/preferences/api"
        help_text: "Regisztrálj a SteamGridDB oldalon, majd a Preferences → API fül alatt kattints a „Generate API key" gombra."

      - env_var: SCREENSCRAPER_USER
        label: "ScreenScraper felhasználónév"
        type: text
        help_url: "https://www.screenscraper.fr/"
        help_text: "Regisztrálj a screenscraper.fr oldalon. A felhasználóneved lesz az API felhasználónév."

      - env_var: SCREENSCRAPER_PASSWORD
        label: "ScreenScraper jelszó"
        type: secret_input
        help_text: "A screenscraper.fr fiókod jelszava."

      - env_var: MOBYGAMES_API_KEY
        label: "MobyGames API Key"
        type: text
        help_url: "https://www.mobygames.com/info/api/"
        help_text: "Regisztrálj a MobyGames oldalon, majd az API oldalon igényelj kulcsot. Részletes játékinformációkat és krediteket biztosít."

8. RoMM docker-compose.yml template — Add missing env vars

File: E:\git\app-catalog-felhom.eu\templates\romm\docker-compose.yml

In the romm service's environment: section, after the existing STEAMGRIDDB line, add:

      - SCREENSCRAPER_USER=${SCREENSCRAPER_USER:-}
      - SCREENSCRAPER_PASSWORD=${SCREENSCRAPER_PASSWORD:-}
      - MOBYGAMES_API_KEY=${MOBYGAMES_API_KEY:-}

Implementation order

  1. metadata.go — add structs + helper methods (safe, no side effects)
  2. deploy.go — add UpdateOptionalConfig + LoadAppConfig + factor out restart logic
  3. router.go — add /optional-config API route + handler
  4. templates.go — add appInfoTmpl, CSS, update allTemplates
  5. server.go — replace appDetailHandler, add template functions
  6. templates.go — update navigation links (stack cards → /apps/{slug}, deploy page → "Részletek")
  7. Commit + push the controller changes
  8. Build + push + deploy the new controller image (see CLAUDE.md for exact commands)
  9. Verify the controller starts correctly and the info page renders
  10. Update RoMM .felhom.yml in app-catalog repo
  11. Update RoMM docker-compose.yml in app-catalog repo
  12. Commit + push the app-catalog changes
  13. Trigger catalog sync on demo-felhom (or wait 15m)
  14. Verify the RoMM info page shows all sections

Testing checklist

  • /apps/romm shows the info page with all sections
  • Optional config fields display with current values (empty for fresh deployment)
  • Saving optional config updates app.yaml and restarts the stack (if deployed)
  • Saving optional config on a non-deployed app saves to app.yaml without error
  • Screenshots section hidden gracefully if no assets exist
  • Stack cards on Alkalmazások page link to /apps/{slug}
  • Deploy page has "Részletek" link back to info page
  • Apps without app_info show minimal info page (header + badges)
  • Protected stacks (traefik, cloudflared, felhom-controller) not affected
  • Build succeeds on build server
  • Controller starts and runs without template parse errors
  • New version shows in docker ps

Step 15: Update documentation (MANDATORY)

After all code changes are done and verified, update these three files:

15a. CONTEXT.md

Add a new "What was just completed" section at the top of the history (push down the current "What was just completed" to "Previously completed"). Include:

  • App info/detail pages feature added
  • New .felhom.yml fields: app_info, optional_config
  • New route: GET /apps/{slug} renders info page (was redirect to deploy)
  • New API: POST /api/stacks/{name}/optional-config
  • RoMM metadata updated with full app_info + 6 metadata provider optional config fields
  • RoMM docker-compose.yml updated with ScreenScraper + MobyGames env vars
  • Navigation: stack cards now link to info page, deploy page has "Részletek" link
  • New controller version: v0.2.11 (or whatever was deployed)

Update the "What's next" section — remove items that are done, add any new priorities.

15b. controller/README.md

Update to reflect:

  • New app_info and optional_config sections in .felhom.yml format
  • New info page route (/apps/{slug})
  • New API endpoint (POST /api/stacks/{name}/optional-config)
  • Add to "What works" list: "App detail/info pages with optional config"

15c. CLAUDE.md

Add to the "Key patterns" section:

  • App info pages at /apps/{slug} — detail view with use cases, setup guide, optional config
  • Optional config saves to app.yaml and restarts deployed apps
  • optional_config fields in .felhom.yml define post-deploy configurable env vars

Add to "Important lessons learned" if any new lessons emerge during implementation.


Debugging tips

  • If template fails to parse, the controller crashes on startup with a template.Must panic. The log output will show the exact parse error (usually missing closing brackets or undefined functions).
  • If app.yaml already has deployed config, UpdateOptionalConfig merges new values without touching locked fields.
  • The onerror on screenshot images hides them if assets don't exist — graceful degradation.
  • config-input uses font-family: monospace intentionally — API keys are easier to verify.
  • The {{index $.CurrentValues .EnvVar}} in the template will return empty string for missing keys (Go template index on map returns zero value) — no nil panic.