32 KiB
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.goServeHTTP()method (manual path matching) - API routes in
internal/api/router.goServeHTTP()method - App metadata lives in
.felhom.ymlfiles parsed byinternal/stacks/metadata.go - Per-app deployed config is saved in
app.yaml(managed byinternal/stacks/deploy.go) - Template functions registered in
server.goinitTemplates()
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
DeployStackcurrently writes.envfiles — there's likely a helper that iteratesappCfg.Envand writesKEY=VALUElines. Reuse that exact logic. - The existing
RestartStack()acquires the mutex lock. SinceUpdateOptionalConfigalready holds the lock, you need an internal unlocked version. Either:- Factor the restart logic out of
RestartStackintorestartStackLocked(recommended), or - Temporarily release and re-acquire the lock (less clean, avoid if possible)
- Factor the restart logic out of
- Look at
manager.gofor 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;
}
6. Update navigation links in templates.go
6a. Stack cards on Alkalmazások page → link to info page
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.
6c. Deploy page → add "Részletek" link
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
metadata.go— add structs + helper methods (safe, no side effects)deploy.go— addUpdateOptionalConfig+LoadAppConfig+ factor out restart logicrouter.go— add/optional-configAPI route + handlertemplates.go— addappInfoTmpl, CSS, updateallTemplatesserver.go— replaceappDetailHandler, add template functionstemplates.go— update navigation links (stack cards →/apps/{slug}, deploy page → "Részletek")- Commit + push the controller changes
- Build + push + deploy the new controller image (see CLAUDE.md for exact commands)
- Verify the controller starts correctly and the info page renders
- Update RoMM
.felhom.ymlin app-catalog repo - Update RoMM
docker-compose.ymlin app-catalog repo - Commit + push the app-catalog changes
- Trigger catalog sync on demo-felhom (or wait 15m)
- Verify the RoMM info page shows all sections
Testing checklist
/apps/rommshows the info page with all sections- Optional config fields display with current values (empty for fresh deployment)
- Saving optional config updates
app.yamland restarts the stack (if deployed) - Saving optional config on a non-deployed app saves to
app.yamlwithout 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_infoshow 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.ymlfields: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_infoandoptional_configsections in.felhom.ymlformat - 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.yamland restarts deployed apps optional_configfields in.felhom.ymldefine 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.Mustpanic. The log output will show the exact parse error (usually missing closing brackets or undefined functions). - If
app.yamlalready has deployed config,UpdateOptionalConfigmerges new values without touching locked fields. - The
onerroron screenshot images hides them if assets don't exist — graceful degradation. config-inputusesfont-family: monospaceintentionally — API keys are easier to verify.- The
{{index $.CurrentValues .EnvVar}}in the template will return empty string for missing keys (Go templateindexon map returns zero value) — no nil panic.