restructured files, and updated for build outside

This commit is contained in:
2026-02-13 19:10:35 +01:00
parent f72bb57eda
commit e9dde0a340
14 changed files with 347 additions and 151 deletions
+231
View File
@@ -0,0 +1,231 @@
package api
import (
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
)
// Router handles all /api/* requests.
type Router struct {
cfg *config.Config
stackMgr *stacks.Manager
logger *log.Logger
}
func NewRouter(cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger) *Router {
return &Router{cfg: cfg, stackMgr: stackMgr, logger: logger}
}
type apiResponse struct {
OK bool `json:"ok"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
// ServeHTTP routes /api/* requests.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
path := strings.TrimPrefix(req.URL.Path, "/api")
path = strings.TrimSuffix(path, "/")
switch {
// GET /api/stacks
case path == "/stacks" && req.Method == http.MethodGet:
r.listStacks(w, req)
// GET /api/stacks/{name}
case strings.HasPrefix(path, "/stacks/") && req.Method == http.MethodGet && !hasSubpath(path, "/stacks/"):
r.getStack(w, req, trimSegment(path, "/stacks/"))
// GET /api/stacks/{name}/deploy-fields
case hasSuffix(path, "/deploy-fields") && req.Method == http.MethodGet:
r.getDeployFields(w, req, extractName(path, "/deploy-fields"))
// POST /api/stacks/{name}/deploy
case hasSuffix(path, "/deploy") && req.Method == http.MethodPost:
r.deployStack(w, req, extractName(path, "/deploy"))
// POST /api/stacks/{name}/start
case hasSuffix(path, "/start") && req.Method == http.MethodPost:
r.actionStack(w, "start", extractName(path, "/start"))
// POST /api/stacks/{name}/stop
case hasSuffix(path, "/stop") && req.Method == http.MethodPost:
r.actionStack(w, "stop", extractName(path, "/stop"))
// POST /api/stacks/{name}/restart
case hasSuffix(path, "/restart") && req.Method == http.MethodPost:
r.actionStack(w, "restart", extractName(path, "/restart"))
// POST /api/stacks/{name}/update
case hasSuffix(path, "/update") && req.Method == http.MethodPost:
r.actionStack(w, "update", extractName(path, "/update"))
// GET /api/stacks/{name}/logs
case hasSuffix(path, "/logs") && req.Method == http.MethodGet:
r.getStackLogs(w, req, extractName(path, "/logs"))
// GET /api/system/info
case path == "/system/info" && req.Method == http.MethodGet:
r.systemInfo(w, req)
default:
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "endpoint not found"})
}
}
// HealthHandler responds to /api/health (no auth required).
func (r *Router) HealthHandler(w http.ResponseWriter, req *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "felhom-controller is healthy"})
}
// --- Stack handlers ---
func (r *Router) listStacks(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: r.stackMgr.GetStacks()})
}
func (r *Router) getStack(w http.ResponseWriter, _ *http.Request, name string) {
stack, ok := r.stackMgr.GetStack(name)
if !ok {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: "stack not found: " + name})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: stack})
}
func (r *Router) getDeployFields(w http.ResponseWriter, _ *http.Request, name string) {
meta, appCfg, err := r.stackMgr.GetDeployFields(name)
if err != nil {
writeJSON(w, http.StatusNotFound, apiResponse{OK: false, Error: err.Error()})
return
}
data := map[string]interface{}{
"metadata": meta,
"app_config": appCfg,
"domain": r.cfg.Customer.Domain,
"logo_url": r.cfg.AppLogoURL(meta.Slug),
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: data})
}
func (r *Router) deployStack(w http.ResponseWriter, req *http.Request, name string) {
r.logger.Printf("[API] Deploy 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
}
deployReq := stacks.DeployRequest{
StackName: name,
Values: body.Values,
}
if err := r.stackMgr.DeployStack(deployReq); err != nil {
r.logger.Printf("[API] Deploy failed for %s: %v", name, err)
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "already deployed") {
status = http.StatusConflict
}
if strings.Contains(err.Error(), "required field") || strings.Contains(err.Error(), "does not exist") {
status = http.StatusBadRequest
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " deployed"})
}
func (r *Router) actionStack(w http.ResponseWriter, action, name string) {
r.logger.Printf("[API] %s requested for stack: %s", action, name)
var err error
switch action {
case "start":
err = r.stackMgr.StartStack(name)
case "stop":
err = r.stackMgr.StopStack(name)
case "restart":
err = r.stackMgr.RestartStack(name)
case "update":
err = r.stackMgr.UpdateStack(name)
}
if err != nil {
status := http.StatusInternalServerError
if strings.Contains(err.Error(), "protected") {
status = http.StatusForbidden
}
if strings.Contains(err.Error(), "not found") {
status = http.StatusNotFound
}
writeJSON(w, status, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Message: "Stack " + name + " " + action + " completed"})
}
func (r *Router) getStackLogs(w http.ResponseWriter, req *http.Request, name string) {
lines := 100
if v := req.URL.Query().Get("lines"); v != "" {
if n, err := strconv.Atoi(v); err == nil && n > 0 {
lines = n
}
}
output, err := r.stackMgr.GetLogs(name, lines)
if err != nil {
writeJSON(w, http.StatusInternalServerError, apiResponse{OK: false, Error: err.Error()})
return
}
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]string{"logs": output}})
}
func (r *Router) systemInfo(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, apiResponse{OK: true, Data: map[string]interface{}{
"customer_id": r.cfg.Customer.ID,
"customer_name": r.cfg.Customer.Name,
"domain": r.cfg.Customer.Domain,
"backup_enabled": r.cfg.Backup.Enabled,
"monitor_enabled": r.cfg.Monitoring.Enabled,
}})
}
// --- Helpers ---
func hasSuffix(path, suffix string) bool { return strings.HasSuffix(path, suffix) }
func hasSubpath(path, prefix string) bool {
rest := strings.TrimPrefix(path, prefix)
return strings.Contains(rest, "/")
}
func trimSegment(path, prefix string) string {
return strings.TrimPrefix(path, prefix)
}
func extractName(path, suffix string) string {
s := strings.TrimPrefix(path, "/stacks/")
return strings.TrimSuffix(s, suffix)
}
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(v); err != nil {
log.Printf("[ERROR] Failed to write JSON response: %v", err)
}
}
+270
View File
@@ -0,0 +1,270 @@
package config
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
// Config is the top-level configuration structure.
// Contains ONLY infrastructure/customer identity.
// App-specific config lives in per-app app.yaml files.
type Config struct {
Customer CustomerConfig `yaml:"customer"`
Infrastructure InfrastructureConfig `yaml:"infrastructure"`
Paths PathsConfig `yaml:"paths"`
Web WebConfig `yaml:"web"`
Git GitConfig `yaml:"git"`
Stacks StacksConfig `yaml:"stacks"`
Backup BackupConfig `yaml:"backup"`
Monitoring MonitoringConfig `yaml:"monitoring"`
SelfUpdate SelfUpdateConfig `yaml:"self_update"`
Notifications NotificationsConfig `yaml:"notifications"`
Logging LoggingConfig `yaml:"logging"`
Assets AssetsConfig `yaml:"assets"`
}
type CustomerConfig struct {
ID string `yaml:"id"`
Name string `yaml:"name"`
Domain string `yaml:"domain"`
Email string `yaml:"email"`
TelegramChatID string `yaml:"telegram_chat_id"`
}
type InfrastructureConfig struct {
CFTunnelToken string `yaml:"cf_tunnel_token"`
CFAPIToken string `yaml:"cf_api_token"`
}
type PathsConfig struct {
StacksDir string `yaml:"stacks_dir"`
DataDir string `yaml:"data_dir"`
BackupDir string `yaml:"backup_dir"`
DBDumpDir string `yaml:"db_dump_dir"`
}
type WebConfig struct {
Listen string `yaml:"listen"`
PasswordHash string `yaml:"password_hash"`
SessionSecret string `yaml:"session_secret"`
}
type GitConfig struct {
RepoURL string `yaml:"repo_url"`
Branch string `yaml:"branch"`
SyncInterval string `yaml:"sync_interval"`
Username string `yaml:"username"`
Token string `yaml:"token"`
}
type StacksConfig struct {
Protected []string `yaml:"protected"`
UpdateWindow string `yaml:"update_window"`
ComposeCommand string `yaml:"compose_command"`
}
type BackupConfig struct {
Enabled bool `yaml:"enabled"`
ResticRepo string `yaml:"restic_repo"`
ResticPasswordFile string `yaml:"restic_password_file"`
DBDumpSchedule string `yaml:"db_dump_schedule"`
ResticSchedule string `yaml:"restic_schedule"`
Retention RetentionConfig `yaml:"retention"`
PruneSchedule string `yaml:"prune_schedule"`
}
type RetentionConfig struct {
KeepDaily int `yaml:"keep_daily"`
KeepWeekly int `yaml:"keep_weekly"`
KeepMonthly int `yaml:"keep_monthly"`
}
type MonitoringConfig struct {
Enabled bool `yaml:"enabled"`
HealthchecksBase string `yaml:"healthchecks_base"`
PingUUIDs PingUUIDsConfig `yaml:"ping_uuids"`
HealthCheckSchedule string `yaml:"health_check_schedule"`
Thresholds ThresholdsConfig `yaml:"thresholds"`
}
type PingUUIDsConfig struct {
DBDump string `yaml:"db_dump"`
Backup string `yaml:"backup"`
SystemHealth string `yaml:"system_health"`
}
type ThresholdsConfig struct {
DiskWarnPercent int `yaml:"disk_warn_percent"`
DiskCritPercent int `yaml:"disk_crit_percent"`
BackupMaxAgeHours int `yaml:"backup_max_age_hours"`
CPUWarnPercent int `yaml:"cpu_warn_percent"`
MemoryWarnPercent int `yaml:"memory_warn_percent"`
TemperatureWarnCelsius int `yaml:"temperature_warn_celsius"`
}
type SelfUpdateConfig struct {
Enabled bool `yaml:"enabled"`
CheckInterval string `yaml:"check_interval"`
Image string `yaml:"image"`
AutoUpdate bool `yaml:"auto_update"`
HealthTimeoutSeconds int `yaml:"health_timeout_seconds"`
}
type NotificationsConfig struct {
CustomerEvents []string `yaml:"customer_events"`
OperatorEvents []string `yaml:"operator_events"`
}
type LoggingConfig struct {
Level string `yaml:"level"`
File string `yaml:"file"`
MaxSizeMB int `yaml:"max_size_mb"`
MaxFiles int `yaml:"max_files"`
}
type AssetsConfig struct {
SourceURL string `yaml:"source_url"` // Only used during build, not runtime
}
// Load reads and parses the config file, applies defaults, and validates.
func Load(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading config file: %w", err)
}
// Expand environment variables in the YAML
expanded := os.ExpandEnv(string(data))
cfg := &Config{}
if err := yaml.Unmarshal([]byte(expanded), cfg); err != nil {
return nil, fmt.Errorf("parsing config file: %w", err)
}
applyDefaults(cfg)
applyEnvOverrides(cfg)
if err := validate(cfg); err != nil {
return nil, fmt.Errorf("config validation: %w", err)
}
return cfg, nil
}
func applyDefaults(cfg *Config) {
d := func(val *string, def string) {
if *val == "" {
*val = def
}
}
di := func(val *int, def int) {
if *val == 0 {
*val = def
}
}
d(&cfg.Paths.StacksDir, "/opt/docker/stacks")
d(&cfg.Paths.DataDir, "/opt/docker/felhom-controller/data")
d(&cfg.Paths.BackupDir, "/srv/backups")
d(&cfg.Paths.DBDumpDir, "/srv/backups/db-dumps")
d(&cfg.Web.Listen, ":8080")
d(&cfg.Git.Branch, "main")
d(&cfg.Git.SyncInterval, "15m")
d(&cfg.Stacks.UpdateWindow, "03:00-05:00")
d(&cfg.Backup.ResticRepo, "/srv/backups/restic-repo")
d(&cfg.Backup.DBDumpSchedule, "02:30")
d(&cfg.Backup.ResticSchedule, "03:00")
d(&cfg.Backup.PruneSchedule, "weekly")
di(&cfg.Backup.Retention.KeepDaily, 7)
di(&cfg.Backup.Retention.KeepWeekly, 4)
di(&cfg.Backup.Retention.KeepMonthly, 6)
d(&cfg.Monitoring.HealthchecksBase, "https://status.felhom.eu")
d(&cfg.Monitoring.HealthCheckSchedule, "06:00")
di(&cfg.Monitoring.Thresholds.DiskWarnPercent, 80)
di(&cfg.Monitoring.Thresholds.DiskCritPercent, 90)
di(&cfg.Monitoring.Thresholds.BackupMaxAgeHours, 36)
di(&cfg.Monitoring.Thresholds.CPUWarnPercent, 90)
di(&cfg.Monitoring.Thresholds.MemoryWarnPercent, 85)
di(&cfg.Monitoring.Thresholds.TemperatureWarnCelsius, 75)
d(&cfg.SelfUpdate.CheckInterval, "6h")
di(&cfg.SelfUpdate.HealthTimeoutSeconds, 60)
d(&cfg.Logging.Level, "info")
di(&cfg.Logging.MaxSizeMB, 10)
di(&cfg.Logging.MaxFiles, 3)
d(&cfg.Assets.SourceURL, "https://felhom.eu")
}
func applyEnvOverrides(cfg *Config) {
envStr := func(key string, target *string) {
if v := os.Getenv(key); v != "" {
*target = v
}
}
envStr("FELHOM_CUSTOMER_ID", &cfg.Customer.ID)
envStr("FELHOM_CUSTOMER_DOMAIN", &cfg.Customer.Domain)
envStr("FELHOM_WEB_LISTEN", &cfg.Web.Listen)
envStr("FELHOM_WEB_PASSWORD_HASH", &cfg.Web.PasswordHash)
envStr("FELHOM_PATHS_STACKS_DIR", &cfg.Paths.StacksDir)
envStr("FELHOM_LOGGING_LEVEL", &cfg.Logging.Level)
}
func validate(cfg *Config) error {
var errs []string
if cfg.Customer.ID == "" {
errs = append(errs, "customer.id is required")
}
if cfg.Customer.Domain == "" {
errs = append(errs, "customer.domain is required")
}
switch cfg.Logging.Level {
case "debug", "info", "warn", "error":
default:
errs = append(errs, fmt.Sprintf("logging.level must be debug|info|warn|error, got %q", cfg.Logging.Level))
}
if cfg.Monitoring.Thresholds.DiskWarnPercent >= cfg.Monitoring.Thresholds.DiskCritPercent {
errs = append(errs, "disk_warn_percent must be less than disk_crit_percent")
}
if len(errs) > 0 {
return fmt.Errorf("validation errors:\n - %s", strings.Join(errs, "\n - "))
}
return nil
}
// IsProtectedStack checks if a stack name is in the protected list.
func (cfg *Config) IsProtectedStack(name string) bool {
for _, p := range cfg.Stacks.Protected {
if strings.EqualFold(p, name) {
return true
}
}
return false
}
// AppLogoURL returns the primary logo URL (SVG). Use AppLogoPNGURL as fallback.
func (cfg *Config) AppLogoURL(slug string) string {
return fmt.Sprintf("/static/assets/%s-logo.svg", slug)
}
// AppLogoPNGURL returns the PNG fallback logo URL.
func (cfg *Config) AppLogoPNGURL(slug string) string {
return fmt.Sprintf("/static/assets/%s-logo.png", slug)
}
// AppScreenshotURL returns the local URL for an app's screenshot.
func (cfg *Config) AppScreenshotURL(slug string, index int) string {
return fmt.Sprintf("/static/assets/%s-screenshot-%d.webp", slug, index)
}
// AppPageURL returns the URL for an app's detail page.
// This links to the local controller-hosted app detail page.
func (cfg *Config) AppPageURL(slug string) string {
return fmt.Sprintf("/apps/%s", slug)
}
+301
View File
@@ -0,0 +1,301 @@
package stacks
import (
"crypto/rand"
"encoding/hex"
"fmt"
"math/big"
"os"
"path/filepath"
"strings"
"time"
"gopkg.in/yaml.v3"
)
// AppConfig holds the per-app deployment configuration.
// Saved as app.yaml in each stack directory after first deployment.
type AppConfig struct {
Deployed bool `yaml:"deployed" json:"deployed"`
DeployedAt string `yaml:"deployed_at" json:"deployed_at"`
Env map[string]string `yaml:"env" json:"env"`
LockedFields []string `yaml:"locked_fields" json:"locked_fields"`
}
// DeployRequest contains the user-provided values from the deploy form.
type DeployRequest struct {
StackName string `json:"stack_name"`
Values map[string]string `json:"values"` // env_var -> user-provided value
}
// DeployStack handles first-time deployment of an app:
// 1. Load metadata (.felhom.yml) to know what fields exist
// 2. Auto-generate secrets for secret/password fields without user values
// 3. Auto-fill domain from controller config
// 4. Merge with user-provided values
// 5. Save app.yaml
// 6. Run docker compose up -d with env vars
func (m *Manager) DeployStack(req DeployRequest) error {
stack, ok := m.GetStack(req.StackName)
if !ok {
return fmt.Errorf("stack %q not found", req.StackName)
}
stackDir := filepath.Dir(stack.ComposePath)
meta := LoadMetadata(stackDir)
// Check if already deployed
existing := LoadAppConfig(stackDir)
if existing != nil && existing.Deployed {
return fmt.Errorf("stack %q is already deployed; use update instead", req.StackName)
}
// Build the full env map
env := make(map[string]string)
var lockedFields []string
for _, field := range meta.DeployFields {
var value string
switch field.Type {
case "domain":
// Auto-fill from controller config
value = m.cfg.Customer.Domain
case "secret":
// Always auto-generate, user never sees these
generated, err := generateValue(field.Generate)
if err != nil {
return fmt.Errorf("generating %s: %w", field.EnvVar, err)
}
value = generated
case "password":
// Use user value if provided, otherwise generate
if userVal, ok := req.Values[field.EnvVar]; ok && userVal != "" {
value = userVal
} else if field.Generate != "" {
generated, err := generateValue(field.Generate)
if err != nil {
return fmt.Errorf("generating %s: %w", field.EnvVar, err)
}
value = generated
}
default:
// text, path, select, boolean — use user value or default
if userVal, ok := req.Values[field.EnvVar]; ok {
value = userVal
} else if field.Default != "" {
value = field.Default
}
}
// Validate required fields
if field.Required && value == "" {
return fmt.Errorf("required field %q (%s) is empty", field.Label, field.EnvVar)
}
// Validate path fields exist
if field.Type == "path" && value != "" {
if _, err := os.Stat(value); os.IsNotExist(err) {
return fmt.Errorf("path %q does not exist for field %q", value, field.Label)
}
}
if value != "" {
env[field.EnvVar] = value
}
if field.LockedAfterDeploy {
lockedFields = append(lockedFields, field.EnvVar)
}
}
// Save app.yaml
appCfg := &AppConfig{
Deployed: true,
DeployedAt: time.Now().UTC().Format(time.RFC3339),
Env: env,
LockedFields: lockedFields,
}
if err := SaveAppConfig(stackDir, appCfg); err != nil {
return fmt.Errorf("saving app config: %w", err)
}
m.logger.Printf("[INFO] Deploying stack %s with %d env vars", req.StackName, len(env))
// Run docker compose up -d
_, err := m.composeExecWithEnv(stackDir, env, "up", "-d")
if err != nil {
// Deployment failed — keep app.yaml for debugging but mark as not deployed
appCfg.Deployed = false
_ = SaveAppConfig(stackDir, appCfg)
return fmt.Errorf("docker compose up failed: %w", err)
}
m.logger.Printf("[INFO] Stack %s deployed successfully", req.StackName)
return m.RefreshStatus()
}
// UpdateStackConfig updates non-locked fields for a deployed stack.
func (m *Manager) UpdateStackConfig(name string, values map[string]string) error {
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
stackDir := filepath.Dir(stack.ComposePath)
appCfg := LoadAppConfig(stackDir)
if appCfg == nil || !appCfg.Deployed {
return fmt.Errorf("stack %q is not deployed yet", name)
}
// Apply changes, respecting locked fields
lockedSet := make(map[string]bool)
for _, f := range appCfg.LockedFields {
lockedSet[f] = true
}
for key, val := range values {
if lockedSet[key] {
return fmt.Errorf("field %q is locked and cannot be changed after deployment", key)
}
appCfg.Env[key] = val
}
if err := SaveAppConfig(stackDir, appCfg); err != nil {
return fmt.Errorf("saving updated config: %w", err)
}
// Restart with new env
_, err := m.composeExecWithEnv(stackDir, appCfg.Env, "up", "-d")
if err != nil {
return fmt.Errorf("restarting with new config: %w", err)
}
m.logger.Printf("[INFO] Stack %s config updated and restarted", name)
return m.RefreshStatus()
}
// composeExecWithEnv runs a compose command with custom env vars injected.
func (m *Manager) composeExecWithEnv(dir string, env map[string]string, args ...string) (string, error) {
// Build env slice: start with os env, then add our vars
cmdEnv := os.Environ()
for k, v := range env {
cmdEnv = append(cmdEnv, fmt.Sprintf("%s=%s", k, v))
}
// Always inject DOMAIN from controller config
cmdEnv = append(cmdEnv, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
return m.composeExecCustomEnv(dir, cmdEnv, args...)
}
// GetDeployFields returns the deployment fields for a stack (for the deploy form).
func (m *Manager) GetDeployFields(name string) (*Metadata, *AppConfig, error) {
stack, ok := m.GetStack(name)
if !ok {
return nil, nil, fmt.Errorf("stack %q not found", name)
}
stackDir := filepath.Dir(stack.ComposePath)
meta := LoadMetadata(stackDir)
appCfg := LoadAppConfig(stackDir)
return &meta, appCfg, nil
}
// --- App config persistence ---
// LoadAppConfig reads app.yaml from a stack directory.
// Returns nil if the file doesn't exist.
func LoadAppConfig(stackDir string) *AppConfig {
path := filepath.Join(stackDir, "app.yaml")
data, err := os.ReadFile(path)
if err != nil {
return nil
}
cfg := &AppConfig{}
if err := yaml.Unmarshal(data, cfg); err != nil {
return nil
}
return cfg
}
// SaveAppConfig writes app.yaml to a stack directory.
func SaveAppConfig(stackDir string, cfg *AppConfig) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling app config: %w", err)
}
path := filepath.Join(stackDir, "app.yaml")
header := "# Auto-generated by felhom-controller — do not edit locked fields manually\n"
content := header + string(data)
if err := os.WriteFile(path, []byte(content), 0600); err != nil {
return fmt.Errorf("writing %s: %w", path, err)
}
return nil
}
// --- Secret generation ---
const alphanumChars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// generateValue creates a random value based on the generator spec.
// Formats: "password:N", "hex:N", "static:VALUE"
func generateValue(spec string) (string, error) {
if spec == "" {
return "", fmt.Errorf("empty generator spec")
}
parts := strings.SplitN(spec, ":", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid generator spec: %q (expected type:param)", spec)
}
genType := parts[0]
param := parts[1]
switch genType {
case "password":
length := 0
if _, err := fmt.Sscanf(param, "%d", &length); err != nil || length <= 0 {
return "", fmt.Errorf("invalid password length: %q", param)
}
return randomAlphanumeric(length)
case "hex":
byteLen := 0
if _, err := fmt.Sscanf(param, "%d", &byteLen); err != nil || byteLen <= 0 {
return "", fmt.Errorf("invalid hex length: %q", param)
}
b := make([]byte, byteLen)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("reading random bytes: %w", err)
}
return hex.EncodeToString(b), nil
case "static":
return param, nil
default:
return "", fmt.Errorf("unknown generator type: %q", genType)
}
}
func randomAlphanumeric(length int) (string, error) {
result := make([]byte, length)
for i := range result {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphanumChars))))
if err != nil {
return "", err
}
result[i] = alphanumChars[n.Int64()]
}
return string(result), nil
}
+452
View File
@@ -0,0 +1,452 @@
package stacks
import (
"bytes"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// ContainerState represents the current state of a container.
type ContainerState string
const (
StateRunning ContainerState = "running"
StateStopped ContainerState = "stopped"
StateRestarting ContainerState = "restarting"
StateExited ContainerState = "exited"
StatePaused ContainerState = "paused"
StateUnknown ContainerState = "unknown"
StateNotDeployed ContainerState = "not_deployed"
)
// ContainerInfo holds status info about a single container within a stack.
type ContainerInfo struct {
Name string `json:"name"`
Image string `json:"image"`
State ContainerState `json:"state"`
Status string `json:"status"` // e.g. "Up 3 hours"
}
// Stack represents a docker compose stack on disk.
type Stack struct {
Name string `json:"name"`
Meta Metadata `json:"meta"`
ComposePath string `json:"compose_path"`
State ContainerState `json:"state"`
Deployed bool `json:"deployed"` // Has app.yaml with deployed=true
Protected bool `json:"protected"`
Containers []ContainerInfo `json:"containers"`
AppConfig *AppConfig `json:"app_config,omitempty"`
LastUpdated time.Time `json:"last_updated"`
}
// Manager handles all docker compose stack operations.
type Manager struct {
cfg *config.Config
logger *log.Logger
composeCmd string
stacks map[string]*Stack
mu sync.RWMutex
}
// NewManager creates a new stack manager.
func NewManager(cfg *config.Config, logger *log.Logger) (*Manager, error) {
composeCmd := cfg.Stacks.ComposeCommand
if composeCmd == "" {
composeCmd = detectComposeCommand()
}
if composeCmd == "" {
return nil, fmt.Errorf("docker compose not found (tried 'docker compose' and 'docker-compose')")
}
logger.Printf("[INFO] Using compose command: %s", composeCmd)
if err := os.MkdirAll(cfg.Paths.StacksDir, 0755); err != nil {
return nil, fmt.Errorf("creating stacks directory %s: %w", cfg.Paths.StacksDir, err)
}
return &Manager{
cfg: cfg,
logger: logger,
composeCmd: composeCmd,
stacks: make(map[string]*Stack),
}, nil
}
// toTitleCase capitalizes the first letter of each word.
func toTitleCase(s string) string {
words := strings.Fields(s)
for i, w := range words {
if len(w) > 0 {
words[i] = strings.ToUpper(w[:1]) + w[1:]
}
}
return strings.Join(words, " ")
}
func detectComposeCommand() string {
if err := exec.Command("docker", "compose", "version").Run(); err == nil {
return "docker compose"
}
if _, err := exec.LookPath("docker-compose"); err == nil {
return "docker-compose"
}
return ""
}
// ScanStacks discovers all compose stacks in the stacks directory.
func (m *Manager) ScanStacks() error {
m.mu.Lock()
defer m.mu.Unlock()
entries, err := os.ReadDir(m.cfg.Paths.StacksDir)
if err != nil {
return fmt.Errorf("reading stacks directory: %w", err)
}
found := make(map[string]bool)
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
stackDir := filepath.Join(m.cfg.Paths.StacksDir, name)
composePath := filepath.Join(stackDir, "docker-compose.yml")
if _, err := os.Stat(composePath); os.IsNotExist(err) {
composePath = filepath.Join(stackDir, "docker-compose.yaml")
if _, err := os.Stat(composePath); os.IsNotExist(err) {
continue
}
}
found[name] = true
meta := LoadMetadata(stackDir)
appCfg := LoadAppConfig(stackDir)
deployed := appCfg != nil && appCfg.Deployed
if existing, ok := m.stacks[name]; ok {
existing.ComposePath = composePath
existing.Meta = meta
existing.Protected = m.cfg.IsProtectedStack(name)
existing.Deployed = deployed
existing.AppConfig = appCfg
} else {
m.stacks[name] = &Stack{
Name: name,
Meta: meta,
ComposePath: composePath,
State: StateNotDeployed,
Deployed: deployed,
Protected: m.cfg.IsProtectedStack(name),
AppConfig: appCfg,
}
}
}
// Remove stacks no longer on disk
for name := range m.stacks {
if !found[name] {
delete(m.stacks, name)
}
}
m.logger.Printf("[INFO] Scanned stacks: %d found", len(m.stacks))
return m.refreshStatusLocked()
}
// RefreshStatus updates container status for all known stacks.
func (m *Manager) RefreshStatus() error {
m.mu.Lock()
defer m.mu.Unlock()
return m.refreshStatusLocked()
}
func (m *Manager) refreshStatusLocked() error {
output, err := m.execCommand("docker", "ps", "-a",
"--format", "{{.Names}}\t{{.Image}}\t{{.State}}\t{{.Status}}\t{{.Label \"com.docker.compose.project\"}}",
"--no-trunc")
if err != nil {
return fmt.Errorf("docker ps: %w", err)
}
projectContainers := make(map[string][]ContainerInfo)
for _, line := range strings.Split(strings.TrimSpace(output), "\n") {
if line == "" {
continue
}
parts := strings.SplitN(line, "\t", 5)
if len(parts) < 5 || parts[4] == "" {
continue
}
ci := ContainerInfo{
Name: parts[0],
Image: parts[1],
State: parseContainerState(parts[2]),
Status: parts[3],
}
projectContainers[parts[4]] = append(projectContainers[parts[4]], ci)
}
for name, stack := range m.stacks {
containers, exists := projectContainers[name]
if !exists {
stack.Containers = nil
if stack.Deployed {
stack.State = StateStopped
} else {
stack.State = StateNotDeployed
}
} else {
stack.Containers = containers
stack.State = aggregateState(containers)
}
stack.LastUpdated = time.Now()
}
return nil
}
func parseContainerState(s string) ContainerState {
switch strings.ToLower(strings.TrimSpace(s)) {
case "running":
return StateRunning
case "exited":
return StateExited
case "restarting":
return StateRestarting
case "paused":
return StatePaused
case "created", "dead", "removing":
return StateStopped
default:
return StateUnknown
}
}
func aggregateState(containers []ContainerInfo) ContainerState {
if len(containers) == 0 {
return StateNotDeployed
}
for _, c := range containers {
if c.State == StateRunning {
return StateRunning
}
}
for _, c := range containers {
if c.State == StateRestarting {
return StateRestarting
}
}
return StateStopped
}
// --- Stack accessors ---
func (m *Manager) GetStacks() []Stack {
m.mu.RLock()
defer m.mu.RUnlock()
result := make([]Stack, 0, len(m.stacks))
for _, s := range m.stacks {
result = append(result, *s)
}
return result
}
func (m *Manager) GetStack(name string) (*Stack, bool) {
m.mu.RLock()
defer m.mu.RUnlock()
s, ok := m.stacks[name]
if !ok {
return nil, false
}
copy := *s
return &copy, true
}
// --- Stack operations ---
// StartStack, StopStack, etc. now load app.yaml env for deployed stacks.
func (m *Manager) StartStack(name string) error {
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
m.logger.Printf("[INFO] Starting stack: %s", name)
dir := filepath.Dir(stack.ComposePath)
env := m.stackEnv(dir)
if _, err := m.composeExecCustomEnv(dir, env, "up", "-d"); err != nil {
return fmt.Errorf("starting stack %s: %w", name, err)
}
m.logger.Printf("[INFO] Stack %s started", name)
return m.RefreshStatus()
}
func (m *Manager) StopStack(name string) error {
if m.cfg.IsProtectedStack(name) {
return fmt.Errorf("stack %q is protected and cannot be stopped", name)
}
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
m.logger.Printf("[INFO] Stopping stack: %s", name)
dir := filepath.Dir(stack.ComposePath)
if _, err := m.composeExec(dir, "down"); err != nil {
return fmt.Errorf("stopping stack %s: %w", name, err)
}
m.logger.Printf("[INFO] Stack %s stopped", name)
return m.RefreshStatus()
}
func (m *Manager) RestartStack(name string) error {
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
m.logger.Printf("[INFO] Restarting stack: %s", name)
dir := filepath.Dir(stack.ComposePath)
if _, err := m.composeExec(dir, "restart"); err != nil {
return fmt.Errorf("restarting stack %s: %w", name, err)
}
m.logger.Printf("[INFO] Stack %s restarted", name)
return m.RefreshStatus()
}
func (m *Manager) UpdateStack(name string) error {
stack, ok := m.GetStack(name)
if !ok {
return fmt.Errorf("stack %q not found", name)
}
m.logger.Printf("[INFO] Updating stack: %s", name)
dir := filepath.Dir(stack.ComposePath)
env := m.stackEnv(dir)
if _, err := m.composeExecCustomEnv(dir, env, "pull"); err != nil {
return fmt.Errorf("pulling images for %s: %w", name, err)
}
if _, err := m.composeExecCustomEnv(dir, env, "up", "-d", "--remove-orphans"); err != nil {
return fmt.Errorf("recreating %s: %w", name, err)
}
m.logger.Printf("[INFO] Stack %s updated", name)
return m.RefreshStatus()
}
func (m *Manager) GetLogs(name string, lines int) (string, error) {
stack, ok := m.GetStack(name)
if !ok {
return "", fmt.Errorf("stack %q not found", name)
}
if lines <= 0 {
lines = 100
}
if lines > 1000 {
lines = 1000
}
dir := filepath.Dir(stack.ComposePath)
output, err := m.composeExec(dir, "logs", "--tail", fmt.Sprintf("%d", lines), "--no-color")
if err != nil {
return "", fmt.Errorf("getting logs for %s: %w", name, err)
}
return output, nil
}
// --- Env and compose helpers ---
// stackEnv builds the full OS env slice for a stack, merging app.yaml values.
func (m *Manager) stackEnv(stackDir string) []string {
env := os.Environ()
// Always inject DOMAIN
env = append(env, fmt.Sprintf("DOMAIN=%s", m.cfg.Customer.Domain))
// Load app.yaml if it exists — merge its env vars
appCfg := LoadAppConfig(stackDir)
if appCfg != nil {
for k, v := range appCfg.Env {
env = append(env, fmt.Sprintf("%s=%s", k, v))
}
}
return env
}
func (m *Manager) composeExec(dir string, args ...string) (string, error) {
return m.composeExecCustomEnv(dir, nil, args...)
}
func (m *Manager) composeExecCustomEnv(dir string, env []string, args ...string) (string, error) {
var cmd *exec.Cmd
if m.composeCmd == "docker compose" {
fullArgs := append([]string{"compose"}, args...)
cmd = exec.Command("docker", fullArgs...)
} else {
cmd = exec.Command("docker-compose", args...)
}
cmd.Dir = dir
if env != nil {
cmd.Env = env
} else {
cmd.Env = m.stackEnv(dir)
}
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
m.logger.Printf("[DEBUG] Running: %s %s (in %s)", m.composeCmd, strings.Join(args, " "), dir)
if err := cmd.Run(); err != nil {
return stdout.String(), fmt.Errorf("%w\nstderr: %s", err, stderr.String())
}
return stdout.String(), nil
}
func (m *Manager) execCommand(name string, args ...string) (string, error) {
cmd := exec.Command(name, args...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
return "", fmt.Errorf("exec %s %s: %w\nstderr: %s", name, strings.Join(args, " "), err, stderr.String())
}
return stdout.String(), nil
}
+132
View File
@@ -0,0 +1,132 @@
package stacks
import (
"os"
"path/filepath"
"strings"
"gopkg.in/yaml.v3"
)
// Metadata holds app information parsed from .felhom.yml.
type Metadata struct {
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"`
}
// ResourceHints describe what the app needs.
type ResourceHints struct {
RAM string `yaml:"ram" json:"ram"`
PiCompatible bool `yaml:"pi_compatible" json:"pi_compatible"`
NeedsHDD bool `yaml:"needs_hdd" json:"needs_hdd"`
}
// DeployField defines one configuration field shown during first deployment.
type DeployField struct {
EnvVar string `yaml:"env_var" json:"env_var"`
Label string `yaml:"label" json:"label"`
Type string `yaml:"type" json:"type"` // domain, secret, password, path, text, select, boolean
Generate string `yaml:"generate" json:"generate"` // e.g., "password:24", "hex:32", "static:admin"
Default string `yaml:"default" json:"default"`
Required bool `yaml:"required" json:"required"`
Placeholder string `yaml:"placeholder" json:"placeholder"`
Description string `yaml:"description" json:"description"`
LockedAfterDeploy bool `yaml:"locked_after_deploy" json:"locked_after_deploy"`
Options []SelectOption `yaml:"options" json:"options,omitempty"`
}
// SelectOption is a choice for "select" type fields.
type SelectOption struct {
Value string `yaml:"value" json:"value"`
Label string `yaml:"label" json:"label"`
}
// LoadMetadata reads .felhom.yml from a stack directory.
// Returns default metadata if the file doesn't exist.
func LoadMetadata(stackDir string) Metadata {
meta := Metadata{}
path := filepath.Join(stackDir, ".felhom.yml")
data, err := os.ReadFile(path)
if err != nil {
// No metadata file — build defaults from directory name
dirName := filepath.Base(stackDir)
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
meta.Slug = dirName
meta.Category = "tools"
return meta
}
if err := yaml.Unmarshal(data, &meta); err != nil {
// Parse error — still return defaults
dirName := filepath.Base(stackDir)
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
meta.Slug = dirName
return meta
}
// Fill in defaults for missing fields
dirName := filepath.Base(stackDir)
if meta.Slug == "" {
meta.Slug = dirName
}
if meta.DisplayName == "" {
meta.DisplayName = toTitleCase(strings.ReplaceAll(dirName, "-", " "))
}
if meta.Category == "" {
meta.Category = "tools"
}
// DOMAIN field is always auto-filled — mark it implicitly required
for i := range meta.DeployFields {
if meta.DeployFields[i].Type == "domain" {
meta.DeployFields[i].Required = true
meta.DeployFields[i].LockedAfterDeploy = true
}
// secret fields are always locked after deploy
if meta.DeployFields[i].Type == "secret" {
meta.DeployFields[i].LockedAfterDeploy = true
}
}
return meta
}
// HasDeployFields returns true if the app has any user-facing deploy fields
// (i.e., fields beyond auto-filled domain and auto-generated secrets).
func (m *Metadata) HasDeployFields() bool {
for _, f := range m.DeployFields {
if f.Type != "domain" && f.Type != "secret" {
return true
}
}
return false
}
// UserFacingFields returns only fields the user needs to interact with.
// Excludes auto-filled (domain) and fully hidden (secret) fields.
func (m *Metadata) UserFacingFields() []DeployField {
var fields []DeployField
for _, f := range m.DeployFields {
if f.Type != "domain" && f.Type != "secret" {
fields = append(fields, f)
}
}
return fields
}
// AutoGeneratedFields returns fields that are generated without user input.
func (m *Metadata) AutoGeneratedFields() []DeployField {
var fields []DeployField
for _, f := range m.DeployFields {
if f.Type == "secret" || f.Type == "domain" {
fields = append(fields, f)
}
}
return fields
}
+405
View File
@@ -0,0 +1,405 @@
package web
import (
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"html/template"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
"gitea.dooplex.hu/admin/felhom-controller/internal/stacks"
"golang.org/x/crypto/bcrypt"
)
type Server struct {
cfg *config.Config
stackMgr *stacks.Manager
logger *log.Logger
version string
tmpl *template.Template
sessions map[string]*session
sessionsMu sync.RWMutex
}
type session struct {
token string
expiresAt time.Time
}
const (
sessionCookieName = "felhom_session"
sessionMaxAge = 24 * time.Hour
)
func NewServer(cfg *config.Config, stackMgr *stacks.Manager, logger *log.Logger, version string) *Server {
s := &Server{
cfg: cfg,
stackMgr: stackMgr,
logger: logger,
version: version,
sessions: make(map[string]*session),
}
s.loadTemplates()
go s.cleanupSessions()
return s
}
func (s *Server) loadTemplates() {
funcMap := template.FuncMap{
"stateColor": func(state stacks.ContainerState) string {
switch state {
case stacks.StateRunning:
return "green"
case stacks.StateStopped, stacks.StateExited:
return "red"
case stacks.StateRestarting:
return "yellow"
default:
return "gray"
}
},
"stateLabel": func(state stacks.ContainerState) string {
switch state {
case stacks.StateRunning:
return "Fut"
case stacks.StateStopped, stacks.StateExited:
return "Leállítva"
case stacks.StateRestarting:
return "Újraindítás..."
case stacks.StateNotDeployed:
return "Nincs telepítve"
case stacks.StatePaused:
return "Szüneteltetve"
default:
return "Ismeretlen"
}
},
"stateIcon": func(state stacks.ContainerState) string {
switch state {
case stacks.StateRunning:
return "●"
case stacks.StateStopped, stacks.StateExited:
return "○"
case stacks.StateRestarting:
return "◐"
default:
return "◌"
}
},
"stateStr": func(state stacks.ContainerState) string {
return string(state)
},
"logoURL": func(slug string) string {
return s.cfg.AppLogoURL(slug)
},
"logoPNGURL": func(slug string) string {
return s.cfg.AppLogoPNGURL(slug)
},
"appPageURL": func(slug string) string {
return s.cfg.AppPageURL(slug)
},
}
s.tmpl = template.Must(template.New("").Funcs(funcMap).Parse(allTemplates))
}
// ServeHTTP handles all non-API web requests.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
path := r.URL.Path
switch {
case path == "/" || path == "/dashboard":
s.dashboardHandler(w, r)
case path == "/stacks":
s.stacksHandler(w, r)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/logs"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/logs")
s.logsHandler(w, r, name)
case strings.HasPrefix(path, "/stacks/") && strings.HasSuffix(path, "/deploy"):
name := strings.TrimPrefix(path, "/stacks/")
name = strings.TrimSuffix(name, "/deploy")
s.deployHandler(w, r, name)
case path == "/static/style.css":
w.Header().Set("Content-Type", "text/css")
w.Header().Set("Cache-Control", "public, max-age=3600")
fmt.Fprint(w, cssContent)
case strings.HasPrefix(path, "/static/assets/"):
// Serve baked-in app assets (logos, screenshots)
s.serveAsset(w, r, strings.TrimPrefix(path, "/static/assets/"))
case strings.HasPrefix(path, "/apps/"):
slug := strings.TrimPrefix(path, "/apps/")
s.appDetailHandler(w, r, slug)
default:
http.NotFound(w, r)
}
}
// RequireAuth returns middleware that checks for valid session or shows login.
func (s *Server) RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth if no password is configured
if s.cfg.Web.PasswordHash == "" {
next.ServeHTTP(w, r)
return
}
if r.URL.Path == "/api/health" {
next.ServeHTTP(w, r)
return
}
if r.URL.Path == "/login" && r.Method == http.MethodPost {
s.handleLogin(w, r)
return
}
if r.URL.Path == "/login" {
s.renderLogin(w, "")
return
}
if r.URL.Path == "/logout" {
s.handleLogout(w, r)
return
}
cookie, err := r.Cookie(sessionCookieName)
if err != nil || !s.isValidSession(cookie.Value) {
if strings.HasPrefix(r.URL.Path, "/api/") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
fmt.Fprint(w, `{"ok":false,"error":"authentication required"}`)
return
}
http.Redirect(w, r, "/login", http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
// --- Auth helpers ---
func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
password := r.FormValue("password")
if password == "" {
s.renderLogin(w, "Kérjük adja meg a jelszót")
return
}
if err := bcrypt.CompareHashAndPassword([]byte(s.cfg.Web.PasswordHash), []byte(password)); err != nil {
s.logger.Printf("[WARN] Failed login from %s", r.RemoteAddr)
s.renderLogin(w, "Hibás jelszó")
return
}
token := s.createSession()
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
MaxAge: int(sessionMaxAge.Seconds()),
HttpOnly: true,
SameSite: http.SameSiteStrictMode,
Secure: true,
})
s.logger.Printf("[INFO] Login from %s", r.RemoteAddr)
http.Redirect(w, r, "/", http.StatusFound)
}
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
if cookie, err := r.Cookie(sessionCookieName); err == nil {
s.sessionsMu.Lock()
delete(s.sessions, cookie.Value)
s.sessionsMu.Unlock()
}
http.SetCookie(w, &http.Cookie{Name: sessionCookieName, Value: "", Path: "/", MaxAge: -1})
http.Redirect(w, r, "/login", http.StatusFound)
}
func (s *Server) createSession() string {
b := make([]byte, 32)
_, _ = rand.Read(b)
token := hex.EncodeToString(b)
s.sessionsMu.Lock()
s.sessions[token] = &session{token: token, expiresAt: time.Now().Add(sessionMaxAge)}
s.sessionsMu.Unlock()
return token
}
func (s *Server) isValidSession(token string) bool {
s.sessionsMu.RLock()
defer s.sessionsMu.RUnlock()
sess, ok := s.sessions[token]
if !ok || time.Now().After(sess.expiresAt) {
return false
}
return subtle.ConstantTimeCompare([]byte(sess.token), []byte(token)) == 1
}
func (s *Server) cleanupSessions() {
for range time.Tick(15 * time.Minute) {
s.sessionsMu.Lock()
now := time.Now()
for t, sess := range s.sessions {
if now.After(sess.expiresAt) {
delete(s.sessions, t)
}
}
s.sessionsMu.Unlock()
}
}
// --- Page handlers ---
func (s *Server) baseData(page, title string) map[string]interface{} {
return map[string]interface{}{
"Page": page,
"Title": title,
"CustomerName": s.cfg.Customer.Name,
"Domain": s.cfg.Customer.Domain,
"Version": s.version,
}
}
func (s *Server) dashboardHandler(w http.ResponseWriter, _ *http.Request) {
stackList := s.stackMgr.GetStacks()
running, stopped := 0, 0
for _, st := range stackList {
switch st.State {
case stacks.StateRunning:
running++
case stacks.StateStopped, stacks.StateExited:
stopped++
}
}
data := s.baseData("dashboard", "Vezérlőpult")
data["Stacks"] = stackList
data["RunningCount"] = running
data["StoppedCount"] = stopped
data["TotalCount"] = len(stackList)
s.render(w, "dashboard", data)
}
func (s *Server) stacksHandler(w http.ResponseWriter, _ *http.Request) {
data := s.baseData("stacks", "Alkalmazások")
data["Stacks"] = s.stackMgr.GetStacks()
s.render(w, "stacks", data)
}
func (s *Server) logsHandler(w http.ResponseWriter, _ *http.Request, name string) {
stack, ok := s.stackMgr.GetStack(name)
if !ok {
http.NotFound(w, nil)
return
}
logs, err := s.stackMgr.GetLogs(name, 200)
if err != nil {
logs = fmt.Sprintf("Hiba a naplók lekérésekor: %v", err)
}
data := s.baseData("logs", stack.Meta.DisplayName+" — Naplók")
data["Stack"] = stack
data["Logs"] = logs
s.render(w, "logs", data)
}
func (s *Server) deployHandler(w http.ResponseWriter, _ *http.Request, name string) {
meta, appCfg, err := s.stackMgr.GetDeployFields(name)
if err != nil {
http.NotFound(w, nil)
return
}
stack, _ := s.stackMgr.GetStack(name)
data := s.baseData("deploy", meta.DisplayName+" — Telepítés")
data["Stack"] = stack
data["Meta"] = meta
data["AppConfig"] = appCfg
data["AlreadyDeployed"] = appCfg != nil && appCfg.Deployed
data["LogoURL"] = s.cfg.AppLogoURL(meta.Slug)
data["LogoPNGURL"] = s.cfg.AppLogoPNGURL(meta.Slug)
data["AppPageURL"] = s.cfg.AppPageURL(meta.Slug)
data["UserFields"] = meta.UserFacingFields()
data["AutoFields"] = meta.AutoGeneratedFields()
s.render(w, "deploy", data)
}
// serveAsset serves baked-in app assets (logos, screenshots) from /usr/share/felhom/assets/
// These are copied into the container at build time.
const assetsDir = "/usr/share/felhom/assets"
func (s *Server) serveAsset(w http.ResponseWriter, r *http.Request, filename string) {
// Sanitize: prevent directory traversal
filename = filepath.Base(filename)
path := filepath.Join(assetsDir, filename)
if _, err := os.Stat(path); os.IsNotExist(err) {
http.NotFound(w, r)
return
}
w.Header().Set("Cache-Control", "public, max-age=86400")
http.ServeFile(w, r, path)
}
// appDetailHandler serves a local app detail page (description, screenshots, FAQ).
// TODO: Phase 1.5 — for now, redirect to the stacks page.
// Future: render a dedicated app page template with baked-in content.
func (s *Server) appDetailHandler(w http.ResponseWriter, r *http.Request, slug string) {
// Find the stack by slug
for _, stack := range s.stackMgr.GetStacks() {
if stack.Meta.Slug == slug {
// For now, redirect to deploy page (if not deployed) or stacks page
if !stack.Deployed {
http.Redirect(w, r, "/stacks/"+stack.Name+"/deploy", http.StatusFound)
} else {
http.Redirect(w, r, "/stacks", http.StatusFound)
}
return
}
}
http.NotFound(w, r)
}
func (s *Server) renderLogin(w http.ResponseWriter, errorMsg string) {
data := map[string]interface{}{
"Title": "Bejelentkezés",
"CustomerName": s.cfg.Customer.Name,
"Error": errorMsg,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "login", data); err != nil {
s.logger.Printf("[ERROR] Template error (login): %v", err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
func (s *Server) render(w http.ResponseWriter, name string, data interface{}) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, name, data); err != nil {
s.logger.Printf("[ERROR] Template error (%s): %v", name, err)
http.Error(w, "Internal error", http.StatusInternalServerError)
}
}
+540
View File
@@ -0,0 +1,540 @@
package web
// All HTML templates and CSS are embedded as Go strings.
// Compiled into the binary — zero external file dependencies at runtime.
// As the UI grows, switch to go:embed for easier editing.
const allTemplates = layoutTmpl + dashboardTmpl + stacksTmpl + loginTmpl + logsTmpl + deployTmpl
const layoutTmpl = `
{{define "layout_start"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{.Title}} — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<nav class="sidebar">
<div class="sidebar-header">
<h1 class="logo">☁ Felhom</h1>
<span class="customer-name">{{.CustomerName}}</span>
</div>
<ul class="nav-links">
<li><a href="/" class="{{if eq .Page "dashboard"}}active{{end}}">📊 Vezérlőpult</a></li>
<li><a href="/stacks" class="{{if eq .Page "stacks"}}active{{end}}">📦 Alkalmazások</a></li>
</ul>
<div class="sidebar-footer">
<span class="version">v{{.Version}}</span>
<a href="/logout" class="logout-link">Kijelentkezés ↗</a>
</div>
</nav>
<main class="content">
{{end}}
{{define "layout_end"}}
</main>
<script>
async function stackAction(name, action) {
const btn = event.currentTarget;
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Folyamatban...';
btn.classList.add('loading');
try {
const resp = await fetch('/api/stacks/' + name + '/' + action, {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const data = await resp.json();
if (!data.ok) {
alert('Hiba: ' + (data.error || 'Ismeretlen hiba'));
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
return;
}
window.location.reload();
} catch (err) {
alert('Hálózati hiba: ' + err.message);
btn.textContent = origText;
btn.disabled = false;
btn.classList.remove('loading');
}
}
</script>
</body>
</html>
{{end}}
`
const dashboardTmpl = `
{{define "dashboard"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>Vezérlőpult</h2>
<span class="domain-badge">{{.Domain}}</span>
</div>
<div class="stats-grid">
<div class="stat-card stat-running">
<div class="stat-value">{{.RunningCount}}</div>
<div class="stat-label">Futó alkalmazás</div>
</div>
<div class="stat-card stat-stopped">
<div class="stat-value">{{.StoppedCount}}</div>
<div class="stat-label">Leállítva</div>
</div>
<div class="stat-card stat-total">
<div class="stat-value">{{.TotalCount}}</div>
<div class="stat-label">Összes alkalmazás</div>
</div>
</div>
<h3>Alkalmazások állapota</h3>
<div class="stack-list">
{{range .Stacks}}
<div class="stack-card stack-state-{{stateColor .State}}">
<div class="stack-info">
<img class="stack-logo" src="{{logoURL .Meta.Slug}}"
alt="{{.Meta.DisplayName}}" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
<div>
<strong class="stack-name">{{.Meta.DisplayName}}</strong>
{{if .Meta.Description}}<span class="stack-desc">{{.Meta.Description}}</span>{{end}}
</div>
</div>
<div class="stack-actions">
<span class="stack-state-label">{{stateLabel .State}}</span>
{{if .Protected}}
<span class="badge badge-protected">🔒 Védett</span>
{{else if not .Deployed}}
<a href="/stacks/{{.Name}}/deploy" class="btn btn-sm btn-primary">🚀 Telepítés</a>
{{else}}
{{if eq (stateStr .State) "running"}}
<button class="btn btn-sm btn-warning" onclick="stackAction('{{.Name}}', 'restart')">↻</button>
<button class="btn btn-sm btn-danger" onclick="stackAction('{{.Name}}', 'stop')">■</button>
{{else}}
<button class="btn btn-sm btn-success" onclick="stackAction('{{.Name}}', 'start')">▶</button>
{{end}}
<a href="/stacks/{{.Name}}/logs" class="btn btn-sm btn-outline">📋</a>
{{end}}
</div>
</div>
{{else}}
<div class="empty-state">
<p>Nincs elérhető alkalmazás.</p>
</div>
{{end}}
</div>
{{template "layout_end" .}}
{{end}}
`
const stacksTmpl = `
{{define "stacks"}}
{{template "layout_start" .}}
<div class="page-header">
<h2>Alkalmazások</h2>
<span class="domain-badge">{{.Domain}}</span>
</div>
<div class="stack-grid">
{{range .Stacks}}
<div class="stack-detail-card stack-state-{{stateColor .State}}">
<div class="stack-detail-header">
<div class="stack-title-row">
<img class="stack-logo-lg" src="{{logoURL .Meta.Slug}}"
alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{logoPNGURL .Meta.Slug}}'">
<div>
<h3>{{.Meta.DisplayName}}</h3>
{{if .Meta.Subdomain}}
<a class="subdomain-link" href="https://{{.Meta.Subdomain}}.{{$.Domain}}" target="_blank">
{{.Meta.Subdomain}}.{{$.Domain}}
</a>
{{end}}
</div>
</div>
<span class="stack-state-badge state-{{stateColor .State}}">{{stateLabel .State}}</span>
</div>
{{if .Meta.Description}}
<p class="stack-detail-desc">{{.Meta.Description}}</p>
{{end}}
<div class="stack-meta-badges">
{{if .Meta.Resources.RAM}}<span class="meta-badge">💾 {{.Meta.Resources.RAM}}</span>{{end}}
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">🥧 Pi kompatibilis</span>{{end}}
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge">💿 HDD szükséges</span>{{end}}
</div>
{{if .Containers}}
<div class="container-list">
{{range .Containers}}
<div class="container-row">
<span class="container-name">{{.Name}}</span>
<span class="container-status state-text-{{stateColor .State}}">{{.Status}}</span>
</div>
{{end}}
</div>
{{end}}
<div class="stack-detail-actions">
{{if .Protected}}
<span class="badge badge-protected">🔒 Védett rendszerkomponens</span>
{{else if not .Deployed}}
<a href="/stacks/{{.Name}}/deploy" class="btn btn-primary">🚀 Telepítés</a>
<a href="{{appPageURL .Meta.Slug}}" target="_blank" class="btn btn-outline">️ Részletek</a>
{{else}}
{{if eq (stateStr .State) "running"}}
<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'update')">⬆ Frissítés</button>
<button class="btn btn-warning" onclick="stackAction('{{.Name}}', 'restart')">↻ Újraindítás</button>
<button class="btn btn-danger" onclick="stackAction('{{.Name}}', 'stop')">■ Leállítás</button>
{{else}}
<button class="btn btn-success" onclick="stackAction('{{.Name}}', 'start')">▶ Indítás</button>
{{end}}
<a href="/stacks/{{.Name}}/logs" class="btn btn-outline">📋 Naplók</a>
<a href="{{appPageURL .Meta.Slug}}" target="_blank" class="btn btn-outline">️ Részletek</a>
{{end}}
</div>
</div>
{{end}}
</div>
{{template "layout_end" .}}
{{end}}
`
const deployTmpl = `
{{define "deploy"}}
{{template "layout_start" .}}
<div class="page-header">
<a href="/stacks" class="btn btn-sm btn-outline">← Vissza</a>
<h2>{{.Meta.DisplayName}} — Telepítés</h2>
</div>
<div class="deploy-container">
<div class="deploy-info">
<img class="deploy-logo" src="{{.LogoURL}}" alt="" onerror="this.onerror=function(){this.style.display='none'};this.src='{{.LogoPNGURL}}'">
<div>
<h3>{{.Meta.DisplayName}}</h3>
{{if .Meta.Description}}<p>{{.Meta.Description}}</p>{{end}}
<div class="stack-meta-badges">
{{if .Meta.Resources.RAM}}<span class="meta-badge">💾 {{.Meta.Resources.RAM}}</span>{{end}}
{{if .Meta.Resources.PiCompatible}}<span class="meta-badge meta-badge-ok">🥧 Pi kompatibilis</span>{{end}}
{{if .Meta.Resources.NeedsHDD}}<span class="meta-badge">💿 HDD szükséges</span>{{end}}
</div>
<a href="{{.AppPageURL}}" target="_blank" class="btn btn-sm btn-outline" style="margin-top:0.5rem">
️ Részletes leírás, képernyőképek
</a>
</div>
</div>
{{if .AlreadyDeployed}}
<div class="alert alert-info">
Ez az alkalmazás már telepítve van. Az alábbi beállítások csak olvashatók.
</div>
{{end}}
<form id="deploy-form" class="deploy-form">
{{if .AutoFields}}
<div class="form-section">
<h4>🔒 Automatikusan generált értékek</h4>
<p class="form-section-desc">Ezek az értékek automatikusan létrejönnek a telepítéskor.</p>
{{range .AutoFields}}
<div class="form-group form-group-auto">
<label>{{.Label}}</label>
<span class="auto-generated-badge">✓ Automatikusan generálva</span>
</div>
{{end}}
</div>
{{end}}
{{if .UserFields}}
<div class="form-section">
<h4>⚙️ Beállítások</h4>
{{range .UserFields}}
<div class="form-group">
<label for="field-{{.EnvVar}}">
{{.Label}}
{{if .Required}}<span class="required">*</span>{{end}}
{{if .LockedAfterDeploy}}<span class="locked-hint">🔒 telepítés után nem módosítható</span>{{end}}
</label>
{{if eq .Type "select"}}
<select id="field-{{.EnvVar}}" name="{{.EnvVar}}" class="form-control"
{{if $.AlreadyDeployed}}disabled{{end}}>
{{range .Options}}
<option value="{{.Value}}">{{.Label}}</option>
{{end}}
</select>
{{else if eq .Type "password"}}
<div class="input-with-button">
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}"
placeholder="{{.Placeholder}}"
{{if $.AlreadyDeployed}}disabled{{end}}>
<button type="button" class="btn btn-sm btn-outline"
onclick="generatePassword('field-{{.EnvVar}}')">🎲 Generálás</button>
</div>
{{else if eq .Type "boolean"}}
<label class="toggle">
<input type="checkbox" id="field-{{.EnvVar}}" name="{{.EnvVar}}" value="true"
{{if $.AlreadyDeployed}}disabled{{end}}>
<span class="toggle-label">Igen</span>
</label>
{{else}}
<input type="text" id="field-{{.EnvVar}}" name="{{.EnvVar}}"
class="form-control" value="{{.Default}}"
placeholder="{{.Placeholder}}"
{{if .Required}}required{{end}}
{{if $.AlreadyDeployed}}disabled{{end}}>
{{end}}
{{if .Description}}
<span class="form-hint">{{.Description}}</span>
{{end}}
</div>
{{end}}
</div>
{{end}}
{{if not .AlreadyDeployed}}
<div class="deploy-actions">
<button type="submit" class="btn btn-primary btn-lg">🚀 Telepítés indítása</button>
<a href="/stacks" class="btn btn-outline">Mégsem</a>
</div>
{{end}}
</form>
</div>
<script>
function generatePassword(fieldId) {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let pass = '';
const arr = new Uint8Array(16);
crypto.getRandomValues(arr);
for (let i = 0; i < 16; i++) {
pass += chars[arr[i] % chars.length];
}
document.getElementById(fieldId).value = pass;
}
document.getElementById('deploy-form').addEventListener('submit', async function(e) {
e.preventDefault();
const btn = e.target.querySelector('[type=submit]');
const origText = btn.textContent;
btn.disabled = true;
btn.textContent = 'Telepítés folyamatban...';
const values = {};
const inputs = e.target.querySelectorAll('input, select');
inputs.forEach(function(el) {
if (el.name && !el.disabled) {
if (el.type === 'checkbox') {
values[el.name] = el.checked ? 'true' : 'false';
} else {
values[el.name] = el.value;
}
}
});
try {
const resp = await fetch('/api/stacks/{{.Stack.Name}}/deploy', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({values: values})
});
const data = await resp.json();
if (!data.ok) {
alert('Hiba: ' + data.error);
btn.textContent = origText;
btn.disabled = false;
return;
}
alert('Sikeres telepítés! ✓');
window.location.href = '/stacks';
} catch (err) {
alert('Hálózati hiba: ' + err.message);
btn.textContent = origText;
btn.disabled = false;
}
});
</script>
{{template "layout_end" .}}
{{end}}
`
const loginTmpl = `
{{define "login"}}
<!DOCTYPE html>
<html lang="hu">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bejelentkezés — Felhom</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body class="login-body">
<div class="login-card">
<h1 class="logo">☁ Felhom</h1>
<p class="login-subtitle">{{.CustomerName}}</p>
{{if .Error}}<div class="alert alert-error">{{.Error}}</div>{{end}}
<form method="POST" action="/login">
<div class="form-group">
<label for="password">Jelszó</label>
<input type="password" id="password" name="password" required autofocus
placeholder="Adja meg a jelszavát" class="form-control">
</div>
<button type="submit" class="btn btn-primary btn-full">Bejelentkezés</button>
</form>
<p class="login-footer">Felhom — Otthoni szerver kezelés<br>
<a href="https://felhom.eu" target="_blank">felhom.eu</a></p>
</div>
</body>
</html>
{{end}}
`
const logsTmpl = `
{{define "logs"}}
{{template "layout_start" .}}
<div class="page-header">
<a href="/stacks" class="btn btn-sm btn-outline">← Vissza</a>
<h2>{{.Stack.Meta.DisplayName}} — Naplók</h2>
</div>
<div class="logs-container">
<pre class="logs-output">{{.Logs}}</pre>
</div>
<div class="logs-actions">
<button class="btn btn-outline" onclick="window.location.reload()">🔄 Frissítés</button>
</div>
{{template "layout_end" .}}
{{end}}
`
// CSS is defined in a separate const for readability.
// Served at /static/style.css
const cssContent = `
:root {
--bg:#f8f9fa; --sidebar-bg:#1a1f36; --sidebar-text:#e2e8f0;
--card-bg:#fff; --text:#1a202c; --text-muted:#718096; --border:#e2e8f0;
--green:#38a169; --green-light:#c6f6d5; --red:#e53e3e; --red-light:#fed7d7;
--yellow:#d69e2e; --yellow-light:#fefcbf; --blue:#3182ce; --blue-light:#bee3f8;
--gray:#a0aec0; --gray-light:#edf2f7; --radius:8px; --shadow:0 1px 3px rgba(0,0,0,.1);
}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);display:flex;min-height:100vh}
.sidebar{width:240px;background:var(--sidebar-bg);color:var(--sidebar-text);display:flex;flex-direction:column;position:fixed;height:100vh;overflow-y:auto}
.sidebar-header{padding:1.5rem;border-bottom:1px solid rgba(255,255,255,.1)}
.logo{font-size:1.5rem;font-weight:700;color:#fff}
.customer-name{display:block;font-size:.85rem;color:var(--gray);margin-top:.25rem}
.nav-links{list-style:none;padding:1rem 0;flex:1}
.nav-links a{display:block;padding:.75rem 1.5rem;color:var(--sidebar-text);text-decoration:none;font-size:.95rem;transition:background .15s}
.nav-links a:hover{background:rgba(255,255,255,.08)}
.nav-links a.active{background:rgba(255,255,255,.12);border-left:3px solid var(--blue)}
.sidebar-footer{padding:1rem 1.5rem;border-top:1px solid rgba(255,255,255,.1);display:flex;justify-content:space-between;align-items:center;font-size:.8rem}
.version{color:var(--gray)} .logout-link{color:var(--gray);text-decoration:none} .logout-link:hover{color:#fff}
.content{margin-left:240px;padding:2rem;flex:1;max-width:1200px}
.page-header{display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem}
.page-header h2{font-size:1.5rem;font-weight:600}
.domain-badge{background:var(--blue-light);color:var(--blue);padding:.25rem .75rem;border-radius:999px;font-size:.8rem;font-weight:500}
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:1rem;margin-bottom:2rem}
.stat-card{background:var(--card-bg);border-radius:var(--radius);padding:1.25rem;box-shadow:var(--shadow);border-left:4px solid var(--gray)}
.stat-running{border-left-color:var(--green)} .stat-stopped{border-left-color:var(--red)} .stat-total{border-left-color:var(--blue)}
.stat-value{font-size:2rem;font-weight:700} .stat-label{color:var(--text-muted);font-size:.85rem;margin-top:.25rem}
.stack-list{display:flex;flex-direction:column;gap:.5rem}
.stack-card{background:var(--card-bg);border-radius:var(--radius);padding:1rem 1.25rem;box-shadow:var(--shadow);display:flex;justify-content:space-between;align-items:center;border-left:4px solid var(--gray)}
.stack-state-green{border-left-color:var(--green)} .stack-state-red{border-left-color:var(--red)} .stack-state-yellow{border-left-color:var(--yellow)} .stack-state-gray{border-left-color:var(--gray)}
.stack-info{display:flex;align-items:center;gap:.75rem}
.stack-logo{width:32px;height:32px;border-radius:6px;object-fit:contain;background:#1c2128;padding:4px}
.stack-logo-lg{width:48px;height:48px;border-radius:8px;object-fit:contain;background:#1c2128;padding:6px}
.stack-name{font-size:1rem} .stack-desc{display:block;font-size:.8rem;color:var(--text-muted)}
.stack-actions{display:flex;align-items:center;gap:.5rem} .stack-state-label{font-size:.8rem;color:var(--text-muted);margin-right:.5rem}
.stack-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(350px,1fr));gap:1rem}
.stack-detail-card{background:var(--card-bg);border-radius:var(--radius);padding:1.25rem;box-shadow:var(--shadow);border-top:4px solid var(--gray)}
.stack-detail-card.stack-state-green{border-top-color:var(--green)} .stack-detail-card.stack-state-red{border-top-color:var(--red)}
.stack-detail-header{display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:.75rem}
.stack-title-row{display:flex;align-items:center;gap:.75rem}
.subdomain-link{font-size:.8rem;color:var(--blue);text-decoration:none} .subdomain-link:hover{text-decoration:underline}
.stack-state-badge{padding:.2rem .6rem;border-radius:999px;font-size:.75rem;font-weight:600;white-space:nowrap}
.state-green{background:var(--green-light);color:var(--green)} .state-red{background:var(--red-light);color:var(--red)}
.state-yellow{background:var(--yellow-light);color:var(--yellow)} .state-gray{background:var(--gray-light);color:var(--gray)}
.state-text-green{color:var(--green)} .state-text-red{color:var(--red)}
.stack-detail-desc{color:var(--text-muted);font-size:.85rem;margin-bottom:.75rem}
.stack-meta-badges{display:flex;flex-wrap:wrap;gap:.4rem;margin:.5rem 0}
.meta-badge{background:var(--gray-light);color:var(--text-muted);padding:.15rem .5rem;border-radius:6px;font-size:.75rem}
.meta-badge-ok{background:var(--green-light);color:var(--green)}
.container-list{margin:.75rem 0} .container-list h4{font-size:.8rem;color:var(--text-muted);margin-bottom:.4rem}
.container-row{display:flex;justify-content:space-between;font-size:.8rem;padding:.2rem 0}
.container-name{font-family:monospace} .container-status{font-size:.75rem}
.stack-detail-actions{display:flex;gap:.5rem;margin-top:1rem;flex-wrap:wrap}
.btn{display:inline-flex;align-items:center;gap:.3rem;padding:.5rem 1rem;border:none;border-radius:6px;font-size:.85rem;font-weight:500;cursor:pointer;transition:opacity .15s,transform .1s;text-decoration:none;color:#fff}
.btn:hover{opacity:.9} .btn:active{transform:scale(.97)} .btn:disabled{opacity:.5;cursor:not-allowed} .btn.loading{opacity:.6}
.btn-sm{padding:.3rem .6rem;font-size:.8rem} .btn-lg{padding:.65rem 1.5rem;font-size:1rem} .btn-full{width:100%;justify-content:center}
.btn-primary{background:var(--blue)} .btn-success{background:var(--green)} .btn-warning{background:var(--yellow);color:#1a202c} .btn-danger{background:var(--red)}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)} .btn-outline:hover{background:var(--gray-light)}
.badge{display:inline-flex;align-items:center;gap:.25rem;padding:.2rem .6rem;border-radius:999px;font-size:.75rem;font-weight:500}
.badge-protected{background:var(--gray-light);color:var(--text-muted)}
/* Deploy page */
.deploy-container{max-width:700px}
.deploy-info{display:flex;gap:1rem;align-items:flex-start;background:var(--card-bg);padding:1.25rem;border-radius:var(--radius);box-shadow:var(--shadow);margin-bottom:1.5rem}
.deploy-logo{width:64px;height:64px;border-radius:12px;object-fit:contain;background:#1c2128;padding:8px;flex-shrink:0}
.deploy-info h3{font-size:1.2rem;margin-bottom:.25rem}
.deploy-info p{color:var(--text-muted);font-size:.9rem}
.deploy-form{background:var(--card-bg);padding:1.5rem;border-radius:var(--radius);box-shadow:var(--shadow)}
.form-section{margin-bottom:1.5rem}
.form-section h4{font-size:1rem;margin-bottom:.5rem}
.form-section-desc{color:var(--text-muted);font-size:.85rem;margin-bottom:.75rem}
.form-group{margin-bottom:1rem}
.form-group label{display:block;font-size:.85rem;font-weight:500;margin-bottom:.4rem}
.form-group-auto{display:flex;justify-content:space-between;align-items:center;padding:.5rem .75rem;background:var(--gray-light);border-radius:6px}
.form-group-auto label{margin:0}
.auto-generated-badge{color:var(--green);font-size:.8rem;font-weight:500}
.form-control{width:100%;padding:.55rem .75rem;border:1px solid var(--border);border-radius:6px;font-size:.9rem;background:#fff}
.form-control:focus{outline:none;border-color:var(--blue);box-shadow:0 0 0 3px rgba(49,130,206,.1)}
.form-control:disabled{background:var(--gray-light);cursor:not-allowed}
.input-with-button{display:flex;gap:.5rem}
.input-with-button .form-control{flex:1}
.form-hint{display:block;font-size:.8rem;color:var(--text-muted);margin-top:.25rem}
.required{color:var(--red)} .locked-hint{font-size:.75rem;color:var(--text-muted);font-weight:400;margin-left:.5rem}
.deploy-actions{display:flex;gap:.75rem;margin-top:1.5rem;padding-top:1rem;border-top:1px solid var(--border)}
.alert{padding:.75rem;border-radius:6px;margin-bottom:1rem;font-size:.85rem}
.alert-error{background:var(--red-light);color:var(--red)} .alert-info{background:var(--blue-light);color:var(--blue)}
/* Logs */
.logs-container{background:#1a1f36;border-radius:var(--radius);padding:1rem;overflow-x:auto;margin-bottom:1rem}
.logs-output{color:#e2e8f0;font-family:'JetBrains Mono','Fira Code',monospace;font-size:.8rem;line-height:1.5;white-space:pre-wrap;word-break:break-all}
.logs-actions{display:flex;gap:.5rem}
.empty-state{text-align:center;padding:3rem;color:var(--text-muted)}
.login-body{display:flex;justify-content:center;align-items:center;min-height:100vh;background:linear-gradient(135deg,#1a1f36,#2d3748)}
.login-card{background:var(--card-bg);padding:2.5rem;border-radius:12px;box-shadow:0 4px 24px rgba(0,0,0,.15);width:100%;max-width:380px;text-align:center}
.login-card .logo{color:var(--text);margin-bottom:.25rem} .login-subtitle{color:var(--text-muted);margin-bottom:1.5rem}
.login-footer{margin-top:1.5rem;font-size:.75rem;color:var(--text-muted)} .login-footer a{color:var(--blue);text-decoration:none}
@media(max-width:768px){
.sidebar{width:100%;height:auto;position:relative}
.nav-links{display:flex;padding:0;overflow-x:auto} .nav-links a{padding:.5rem 1rem;white-space:nowrap}
.content{margin-left:0;padding:1rem} body{flex-direction:column}
.stack-card{flex-direction:column;align-items:flex-start;gap:.75rem} .stack-actions{width:100%;justify-content:flex-end}
.stack-grid{grid-template-columns:1fr} .stats-grid{grid-template-columns:repeat(3,1fr)}
.deploy-info{flex-direction:column}
}
`