updated CLAUDE.md and TASK.md

This commit is contained in:
2026-02-14 20:00:24 +01:00
parent a8096faf59
commit 528f64cab7
2 changed files with 1041 additions and 31 deletions
+97 -21
View File
@@ -12,6 +12,7 @@ Compose stacks on customer hardware via a Hungarian-language web dashboard.
See `controller/README.md` for full architecture and status.
See `CONTEXT.md` for current project state, recent work, and decisions (update after each session).
See `TASK.md` for the current task to implement (if it exists).
Claude in Chrome extension is available — can be used to test web UI on demo-felhom.eu or verify dashboard deployments in browser.
@@ -22,13 +23,14 @@ Claude in Chrome extension is available — can be used to test web UI on demo-f
- Add debug capabilities (logging, verbose output) for easier troubleshooting
- If you need more input or troubleshooting command output, ask first — don't guess
## Repository structure
## Workspace layout
This repo has two main parts:
Claude Code runs on Windows. The working directory is `E:\git\` (mapped as `/e/git/` in Git Bash).
This repo is at:
```
deploy-felhom-compose/
├── controller/ # Go application (this is the main codebase)
E:\git\deploy-felhom-compose\ (or /e/git/deploy-felhom-compose/ in Git Bash)
├── controller/ # Go application (main codebase)
│ ├── cmd/controller/ # Entry point (main.go)
│ ├── internal/
│ │ ├── config/ # YAML config loading
@@ -42,17 +44,28 @@ deploy-felhom-compose/
│ └── go.mod
├── scripts/ # Setup scripts for customer nodes
├── CLAUDE.md # This file
── CONTEXT.md # Project memory / state
── CONTEXT.md # Project memory / state
└── TASK.md # Current task (if exists)
```
## Related repositories (not in this repo)
Related repos (same parent directory):
```
E:\git\app-catalog-felhom.eu\ # Docker Compose templates + .felhom.yml metadata per app
E:\git\felhom.eu\ # Website (htmls) + k3s manifests
E:\git\homelab-manifests\ # k3s cluster manifests (dooplex.hu services)
E:\git\misc-scripts\ # Helper scripts
```
- **app-catalog-felhom.eu** — Docker Compose templates + .felhom.yml metadata per app
- **felhom.eu** — Website (htmls) + k3s manifests for the web server
- **homelab-manifests** — Viktor's k3s cluster manifests (dooplex.hu services)
- **misc-scripts** — Helper scripts for daily tasks
All repos hosted at `gitea.dooplex.hu/admin/`. Git credentials are stored (`git config credential.helper store`).
All hosted at `gitea.dooplex.hu/admin/`
## SSH access
SSH key-based authentication is configured and working. No password prompts.
| Host | IP | User | Role |
|------|----|------|------|
| Build server | 192.168.0.180 | kisfenyo | Build + push container images |
| Demo node | 192.168.0.162 | kisfenyo | Test deployment (demo-felhom.eu) |
## Test environments
@@ -64,20 +77,73 @@ All hosted at `gitea.dooplex.hu/admin/`
- Pi-hole DNS on local network forwards `*.demo-felhom.eu` → 192.168.0.162
- External access via Cloudflare Tunnel → Traefik reverse proxy
## Build & deploy workflow
## Build & deploy workflow — MANDATORY
After making code changes to the controller, you **MUST** build, push, and deploy the new image.
Do NOT leave code changes uncommitted or undeployed. The full cycle is:
### Step 1: Commit and push changes
Code is edited locally on Windows. Build happens on the server via SSH.
```bash
# Push changes
git add -A && git commit -m "..." && git push
# Build + push image on server
ssh kisfenyo@192.168.0.180 "cd ~/build/felhom-controller && git -C ~/git/deploy-felhom-compose pull && ./build.sh --push"
# Deploy on demo node
ssh kisfenyo@192.168.0.162 "cd /opt/docker/felhom-controller && docker pull gitea.dooplex.hu/admin/felhom-controller: && docker compose up -d"
cd /e/git/deploy-felhom-compose
git add -A && git commit -m "<descriptive message>" && git push
```
### Step 2: Build + push the container image on the build server
The build server (192.168.0.180) has the build toolchain. The version tag should be incremented
from the current running version.
First, check the current running version:
```bash
ssh kisfenyo@192.168.0.162 "docker ps --filter name=felhom-controller --format '{{.Image}}'"
```
Then build with the next version (e.g., if current is 0.2.10, use 0.2.11):
```bash
ssh kisfenyo@192.168.0.180 "cd ~/build/felhom-controller && git -C ~/git/deploy-felhom-compose pull && ./build.sh <NEW_VERSION> --push"
```
The build script:
- Pulls latest code from Gitea
- Builds a multi-arch Docker image (amd64 + arm64) if `--multiarch`, or current arch if `--push`
- Pushes to `gitea.dooplex.hu/admin/felhom-controller:<VERSION>`
- Expects the version as first argument (e.g., `0.2.11`)
### Step 3: Deploy on the demo node
```bash
ssh kisfenyo@192.168.0.162 "cd /opt/docker/felhom-controller && docker pull gitea.dooplex.hu/admin/felhom-controller:<NEW_VERSION> && sed -i 's|felhom-controller:[^ ]*|felhom-controller:<NEW_VERSION>|' docker-compose.yml && docker compose up -d"
```
### Step 4: Verify the deployment
```bash
ssh kisfenyo@192.168.0.162 "docker ps --filter name=felhom-controller --format '{{.Image}} {{.Status}}'"
```
Should show the new version and "Up" status. Also check logs for startup errors:
```bash
ssh kisfenyo@192.168.0.162 "docker logs felhom-controller --tail 20"
```
### Build workflow summary
| Step | Command | Where |
|------|---------|-------|
| 1. Commit + push | `git add -A && git commit -m "..." && git push` | Local (this repo) |
| 2. Build + push image | `ssh 192.168.0.180 "... ./build.sh <VER> --push"` | Build server |
| 3. Deploy | `ssh 192.168.0.162 "... docker compose up -d"` | Demo node |
| 4. Verify | `ssh 192.168.0.162 "docker ps ..."` | Demo node |
**IMPORTANT:** If you make changes to the app-catalog-felhom.eu repo, commit and push those too:
```bash
cd /e/git/app-catalog-felhom.eu
git add -A && git commit -m "<message>" && git push
```
The controller's git sync will pick up catalog changes within 15 minutes, or you can trigger it
manually via the dashboard "Sablonok frissítése" button.
## Tech stack
- **Language:** Go 1.22+
@@ -144,3 +210,13 @@ Key patterns used in `internal/stacks/`:
6. `docker compose up -d` returns exit 0 even when containers crash-loop — post-start status check is essential for detecting failures
7. Mealie image has no wget/curl — use Python TCP socket check for healthcheck; set `start_period: 60s` for DB migration time
8. Always verify container images have the healthcheck tool (`wget`, `curl`, etc.) before using it — Alpine has BusyBox wget, Python images have `python3`
## End-of-session checklist
Before ending a session, always:
1. **Commit and push** all code changes
2. **Build, push, and deploy** the new controller image (if controller code changed)
3. **Update CONTEXT.md** with what was done, decisions made, and what's next
4. **Update controller/README.md** if architecture or features changed
5. **Verify** the deployment is working (check `docker ps` and logs)
+934
View File
@@ -0,0 +1,934 @@
# 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" .}}
<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
/* --- 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:
```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
# =============================================================================
# .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:
```yaml
- 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.