updated TASK.md - logo resize, app info missing

This commit is contained in:
2026-02-14 20:27:49 +01:00
parent 658b995e53
commit 55d7b7d370
+104 -776
View File
@@ -1,716 +1,104 @@
# TASK.md — Current Task: App Detail/Info Pages # TASK.md — Fix: App Info Page Bugs
> Read CLAUDE.md first for project context, workspace layout, and build instructions. > Read CLAUDE.md first for project context, workspace layout, and build instructions.
> This file describes the current task to implement.
## Overview ## Problems
Add a dedicated app information page to the felhom-controller dashboard. This page shows detailed Two bugs on the `/apps/romm` info page:
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 ### Bug 1: Logo SVG renders at native size (fills entire viewport)
metadata provider API keys (IGDB, SteamGridDB, ScreenScraper, MobyGames).
## Architecture context The `.app-info-logo` CSS sets `width: 80px; height: 80px` but the SVG logo image ignores these
constraints and renders at its intrinsic size (hundreds of pixels wide/tall), pushing all content
off-screen.
- All HTML/CSS is embedded as Go string constants in `internal/web/templates.go` **Root cause:** SVG images inside `<img>` tags can ignore `width`/`height` CSS if the SVG file
- All UI text is in **Hungarian** itself has explicit `width`/`height` attributes or no `viewBox`. The `object-fit: contain` only
- Routes are defined in `internal/web/server.go` `ServeHTTP()` method (manual path matching) works when the container actually constrains the image.
- 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 **Fix:** Make the logo constraint bulletproof. Update the `.app-info-logo` CSS in `templates.go`:
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" .}}
<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):
```css ```css
/* --- 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 { .app-info-logo {
width: 80px; width: 80px;
height: 80px; height: 80px;
min-width: 80px;
min-height: 80px;
max-width: 80px;
max-height: 80px;
border-radius: 12px; border-radius: 12px;
object-fit: contain; object-fit: contain;
background: var(--bg-secondary); background: var(--bg-secondary);
padding: 10px; padding: 10px;
flex-shrink: 0; flex-shrink: 0;
} overflow: hidden;
.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;
} }
``` ```
The key additions are `max-width`, `max-height`, and `overflow: hidden` which will force the
image to stay within bounds regardless of the SVG's intrinsic dimensions.
### Bug 2: App info sections not rendering (use cases, first steps, optional config all missing)
The hero section renders (logo + badges) but the `app_info` content (tagline, use cases, first
steps, prerequisites, default creds, docs link) and `optional_config` (metadata provider fields)
are completely absent. The `<p class="app-info-tagline">` is empty in the DOM.
This means `HasAppInfo()` returns false and `HasOptionalConfig()` returns false, which means the
`.felhom.yml` file on the demo node does NOT contain the new `app_info` and `optional_config`
sections.
**Diagnosis steps (run these first):**
```bash
# 1. Check if the stacks dir has the updated .felhom.yml
ssh kisfenyo@192.168.0.162 "cat /opt/docker/stacks/romm/.felhom.yml"
```
If the output does NOT contain `app_info:` and `optional_config:`, the catalog sync hasn't
picked up the changes. Check:
```bash
# 2. Check if app-catalog repo was actually updated
cd /e/git/app-catalog-felhom.eu
git log --oneline -3
cat templates/romm/.felhom.yml | grep -c "app_info"
```
If the local repo doesn't have `app_info` in the romm `.felhom.yml`, the previous session
didn't update the app-catalog repo. You need to:
1. Update `E:\git\app-catalog-felhom.eu\templates\romm\.felhom.yml` with the full content
(see "RoMM .felhom.yml content" section below)
2. Update `E:\git\app-catalog-felhom.eu\templates\romm\docker-compose.yml` — add the missing
ScreenScraper + MobyGames env vars (see "RoMM docker-compose.yml changes" section below)
3. Commit and push the app-catalog repo
4. Trigger a catalog sync on the demo node (or wait 15 minutes)
If the local repo DOES have `app_info` but the demo node doesn't, force a sync:
```bash
# Trigger sync via API (need to login first to get session cookie)
# Or use the dashboard "Sablonok frissítése" button
```
**After the catalog sync, verify the fix:**
```bash
ssh kisfenyo@192.168.0.162 "grep -c 'app_info' /opt/docker/stacks/romm/.felhom.yml"
# Should return 1 (meaning the app_info section exists)
ssh kisfenyo@192.168.0.162 "grep -c 'optional_config' /opt/docker/stacks/romm/.felhom.yml"
# Should return 1
```
Then reload `/apps/romm` in the browser — the info cards and optional config form should appear.
--- ---
### 6. Update navigation links in `templates.go` ## RoMM .felhom.yml content
#### 6a. Stack cards on Alkalmazások page → link to info page Replace `templates/romm/.felhom.yml` in the **app-catalog-felhom.eu** repo entirely with:
In `stacksTmpl`, the cards currently have `data-href="/stacks/{{.Name}}/deploy"`.
Change to:
```html
{{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:
```html
<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:
```yaml ```yaml
# ============================================================================= # =============================================================================
@@ -833,11 +221,10 @@ optional_config:
--- ---
### 8. RoMM docker-compose.yml template — Add missing env vars ## RoMM docker-compose.yml changes
File: `E:\git\app-catalog-felhom.eu\templates\romm\docker-compose.yml` In `E:\git\app-catalog-felhom.eu\templates\romm\docker-compose.yml`, in the romm service's
`environment:` section, after the existing `STEAMGRIDDB_API_KEY` line, add these three lines:
In the romm service's `environment:` section, after the existing STEAMGRIDDB line, add:
```yaml ```yaml
- SCREENSCRAPER_USER=${SCREENSCRAPER_USER:-} - SCREENSCRAPER_USER=${SCREENSCRAPER_USER:-}
@@ -849,86 +236,27 @@ In the romm service's `environment:` section, after the existing STEAMGRIDDB lin
## Implementation order ## Implementation order
1. `metadata.go` — add structs + helper methods (safe, no side effects) 1. **Diagnose Bug 2 first** — run the SSH commands above to check if `.felhom.yml` on the node
2. `deploy.go` — add `UpdateOptionalConfig` + `LoadAppConfig` + factor out restart logic has the `app_info` section. This tells us whether the catalog update was missed or sync failed.
3. `router.go` — add `/optional-config` API route + handler 2. **Fix Bug 1** — update `.app-info-logo` CSS in `controller/internal/web/templates.go`
4. `templates.go` — add `appInfoTmpl`, CSS, update `allTemplates` 3. **Fix Bug 2** — if the app-catalog wasn't updated:
5. `server.go` — replace `appDetailHandler`, add template functions a. Update `templates/romm/.felhom.yml` in `E:\git\app-catalog-felhom.eu\`
6. `templates.go` — update navigation links (stack cards → `/apps/{slug}`, deploy page → "Részletek") b. Update `templates/romm/docker-compose.yml` in `E:\git\app-catalog-felhom.eu\`
7. **Commit + push** the controller changes c. Commit + push the app-catalog repo:
8. **Build + push + deploy** the new controller image (see CLAUDE.md for exact commands) ```bash
9. **Verify** the controller starts correctly and the info page renders cd /e/git/app-catalog-felhom.eu
10. Update RoMM `.felhom.yml` in app-catalog repo git add -A && git commit -m "romm: add app_info + optional_config metadata, add ScreenScraper + MobyGames env vars" && git push
11. Update RoMM `docker-compose.yml` in app-catalog repo ```
12. **Commit + push** the app-catalog changes 4. **Build + deploy** the controller with the CSS fix (follow CLAUDE.md build workflow)
13. Trigger catalog sync on demo-felhom (or wait 15m) 5. **Trigger catalog sync** — either wait 15m or use the dashboard "Sablonok frissítése" button
14. **Verify** the RoMM info page shows all sections 6. **Verify** — reload `/apps/romm` and confirm:
- [ ] Logo is 80x80, not filling the screen
--- - [ ] Tagline text visible: "Retró játékgyűjtemény kezelő, böngésző és lejátszó"
- [ ] "Mire használható?" card with 5 use cases
## Testing checklist - [ ] "Első lépések" card with 6 steps
- [ ] "Előfeltételek" card with 3 items
- [ ] `/apps/romm` shows the info page with all sections - [ ] "Alapértelmezett belépés" card showing "admin / admin"
- [ ] Optional config fields display with current values (empty for fresh deployment) - [ ] "Dokumentáció" card with link to RomM wiki
- [ ] Saving optional config updates `app.yaml` and restarts the stack (if deployed) - [ ] "Opcionális beállítások" section with 6 metadata provider fields
- [ ] Saving optional config on a non-deployed app saves to `app.yaml` without error - [ ] "Mentés" button on optional config form
- [ ] Screenshots section hidden gracefully if no assets exist 7. **Update CONTEXT.md** with the fixes applied
- [ ] 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.