updated CLAUDE.md and TASK.md
This commit is contained in:
@@ -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 `controller/README.md` for full architecture and status.
|
||||||
See `CONTEXT.md` for current project state, recent work, and decisions (update after each session).
|
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.
|
Claude in Chrome extension is available — can be used to test web UI on demo-felhom.eu or verify dashboard deployments in browser.
|
||||||
|
|
||||||
@@ -22,37 +23,49 @@ 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
|
- Add debug capabilities (logging, verbose output) for easier troubleshooting
|
||||||
- If you need more input or troubleshooting command output, ask first — don't guess
|
- 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/
|
E:\git\deploy-felhom-compose\ (or /e/git/deploy-felhom-compose/ in Git Bash)
|
||||||
├── controller/ # Go application (this is the main codebase)
|
├── controller/ # Go application (main codebase)
|
||||||
│ ├── cmd/controller/ # Entry point (main.go)
|
│ ├── cmd/controller/ # Entry point (main.go)
|
||||||
│ ├── internal/
|
│ ├── internal/
|
||||||
│ │ ├── config/ # YAML config loading
|
│ │ ├── config/ # YAML config loading
|
||||||
│ │ ├── stacks/ # Docker Compose operations, deploy flow
|
│ │ ├── stacks/ # Docker Compose operations, deploy flow
|
||||||
│ │ ├── sync/ # Git sync — periodic pull of app catalog repo
|
│ │ ├── sync/ # Git sync — periodic pull of app catalog repo
|
||||||
│ │ ├── api/ # REST API endpoints
|
│ │ ├── api/ # REST API endpoints
|
||||||
│ │ ├── system/ # System info (memory, disk)
|
│ │ ├── system/ # System info (memory, disk)
|
||||||
│ │ └── web/ # Dashboard UI (embedded HTML/CSS templates)
|
│ │ └── web/ # Dashboard UI (embedded HTML/CSS templates)
|
||||||
│ ├── Dockerfile
|
│ ├── Dockerfile
|
||||||
│ ├── Makefile
|
│ ├── Makefile
|
||||||
│ └── go.mod
|
│ └── go.mod
|
||||||
├── scripts/ # Setup scripts for customer nodes
|
├── scripts/ # Setup scripts for customer nodes
|
||||||
├── CLAUDE.md # This file
|
├── 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
|
All repos hosted at `gitea.dooplex.hu/admin/`. Git credentials are stored (`git config credential.helper store`).
|
||||||
- **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 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
|
## 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
|
- Pi-hole DNS on local network forwards `*.demo-felhom.eu` → 192.168.0.162
|
||||||
- External access via Cloudflare Tunnel → Traefik reverse proxy
|
- 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
|
```bash
|
||||||
# Push changes
|
cd /e/git/deploy-felhom-compose
|
||||||
git add -A && git commit -m "..." && git push
|
git add -A && git commit -m "<descriptive message>" && 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"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 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
|
## Tech stack
|
||||||
|
|
||||||
- **Language:** Go 1.22+
|
- **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
|
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
|
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`
|
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)
|
||||||
@@ -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.
|
||||||
Reference in New Issue
Block a user