eec1afae23
Both autoProcessHubRestore and processHubRestore rendered the progress page (setup_restore_exec) without starting the executeHubRestore() goroutine, causing the template to poll forever showing "Indítás...". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1085 lines
35 KiB
Go
1085 lines
35 KiB
Go
package setup
|
|
|
|
import (
|
|
crand "crypto/rand"
|
|
"crypto/sha256"
|
|
"embed"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html/template"
|
|
"log"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/backup"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/report"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/web"
|
|
|
|
"golang.org/x/crypto/bcrypt"
|
|
)
|
|
|
|
//go:embed templates/*.html
|
|
var templateFS embed.FS
|
|
|
|
// Server handles the setup wizard HTTP routes.
|
|
type Server struct {
|
|
cfg *config.Config
|
|
dataDir string
|
|
logger *log.Logger
|
|
tmpl *template.Template
|
|
state *SetupState
|
|
version string
|
|
|
|
// Scan state for async drive scanning
|
|
scanMu sync.Mutex
|
|
scanRunning bool
|
|
scanResults []DriveBackup
|
|
scanDone bool
|
|
scanError string
|
|
|
|
// Restore progress
|
|
restoreMu sync.Mutex
|
|
restoreRunning bool
|
|
restoreSteps []RestoreStep
|
|
restoreError string
|
|
restoreDone bool
|
|
}
|
|
|
|
// RestoreStep tracks progress of a restore operation.
|
|
type RestoreStep struct {
|
|
Label string `json:"label"`
|
|
Status string `json:"status"` // "pending", "running", "done", "failed"
|
|
Error string `json:"error,omitempty"`
|
|
}
|
|
|
|
// NewServer creates a new setup wizard server.
|
|
func NewServer(cfg *config.Config, dataDir string, logger *log.Logger, version string) *Server {
|
|
s := &Server{
|
|
cfg: cfg,
|
|
dataDir: dataDir,
|
|
logger: logger,
|
|
state: LoadState(dataDir),
|
|
version: version,
|
|
}
|
|
s.loadTemplates()
|
|
|
|
// Pre-seed setup state from env vars (set by docker-setup.sh --hub-customer)
|
|
if cid := os.Getenv("FELHOM_SETUP_CUSTOMER_ID"); cid != "" {
|
|
s.state.SetFormField("customer_id", cid)
|
|
logger.Printf("[INFO] Setup: pre-seeded customer_id from env: %s", cid)
|
|
}
|
|
if pw := os.Getenv("FELHOM_SETUP_PASSWORD"); pw != "" {
|
|
s.state.SetFormField("retrieval_password", pw)
|
|
logger.Printf("[INFO] Setup: pre-seeded retrieval_password from env")
|
|
}
|
|
if s.isHubPreseeded() {
|
|
s.state.Save()
|
|
}
|
|
|
|
return s
|
|
}
|
|
|
|
// isDebug returns true if logging level is "debug".
|
|
func (s *Server) isDebug() bool {
|
|
return s.cfg != nil && s.cfg.Logging.Level == "debug"
|
|
}
|
|
|
|
// isHubPreseeded returns true if both customer_id and retrieval_password are
|
|
// pre-seeded in setup state (set by docker-setup.sh --hub-customer).
|
|
func (s *Server) isHubPreseeded() bool {
|
|
return s.state.GetFormField("customer_id") != "" && s.state.GetFormField("retrieval_password") != ""
|
|
}
|
|
|
|
func (s *Server) loadTemplates() {
|
|
s.tmpl = template.Must(
|
|
template.New("").Funcs(template.FuncMap{
|
|
"timeNow": func() string { return time.Now().Format("2006-01-02 15:04") },
|
|
}).ParseFS(templateFS, "templates/*.html"),
|
|
)
|
|
}
|
|
|
|
// Handler returns the HTTP handler for the setup wizard.
|
|
func (s *Server) Handler() http.Handler {
|
|
mux := http.NewServeMux()
|
|
|
|
mux.HandleFunc("/", s.handleRoot)
|
|
mux.HandleFunc("/setup", s.handleWelcome)
|
|
mux.HandleFunc("/setup/scan", s.handleScan)
|
|
mux.HandleFunc("/setup/scan/status", s.handleScanStatus)
|
|
mux.HandleFunc("/setup/hub-restore", s.handleHubRestore)
|
|
mux.HandleFunc("/setup/restore", s.handleRestore)
|
|
mux.HandleFunc("/setup/restore/status", s.handleRestoreStatus)
|
|
mux.HandleFunc("/setup/fresh", s.handleFreshHub)
|
|
mux.HandleFunc("/setup/manual", s.handleManual)
|
|
mux.HandleFunc("/setup/failed", s.handleFailed)
|
|
mux.HandleFunc("/static/style.css", s.handleCSS)
|
|
mux.HandleFunc("/static/felhom-logo.svg", s.handleLogo)
|
|
|
|
return mux
|
|
}
|
|
|
|
// --- Route Handlers ---
|
|
|
|
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path != "/" {
|
|
http.Redirect(w, r, "/setup", http.StatusFound)
|
|
return
|
|
}
|
|
http.Redirect(w, r, "/setup", http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) handleWelcome(w http.ResponseWriter, r *http.Request) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
domain := s.cfg.Customer.Domain
|
|
ips := DetectLocalIPs()
|
|
|
|
var accessURLs []string
|
|
if domain != "" {
|
|
accessURLs = append(accessURLs, fmt.Sprintf("https://felhom.%s", domain))
|
|
}
|
|
for _, ip := range ips {
|
|
accessURLs = append(accessURLs, fmt.Sprintf("http://%s%s", ip, s.cfg.Web.SetupListen))
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"AccessURLs": accessURLs,
|
|
"Version": s.version,
|
|
"HubMode": s.isHubPreseeded(),
|
|
"HubCustomerID": s.state.GetFormField("customer_id"),
|
|
}
|
|
s.render(w, "setup_welcome", data)
|
|
}
|
|
|
|
func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
|
|
// Start scan if not already running
|
|
s.scanMu.Lock()
|
|
if !s.scanRunning && !s.scanDone {
|
|
s.scanRunning = true
|
|
go s.runDriveScan()
|
|
}
|
|
s.scanMu.Unlock()
|
|
|
|
s.state.SetStep("scan")
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
}
|
|
s.render(w, "setup_scan", data)
|
|
}
|
|
|
|
func (s *Server) handleScanStatus(w http.ResponseWriter, r *http.Request) {
|
|
s.scanMu.Lock()
|
|
defer s.scanMu.Unlock()
|
|
|
|
resp := map[string]interface{}{
|
|
"running": s.scanRunning,
|
|
"done": s.scanDone,
|
|
"results": s.scanResults,
|
|
"error": s.scanError,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func (s *Server) handleHubRestore(w http.ResponseWriter, r *http.Request) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
|
|
if r.Method == http.MethodPost {
|
|
if !validateCSRF(r) {
|
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
s.processHubRestore(w, r)
|
|
return
|
|
}
|
|
|
|
// Auto-process if credentials are pre-seeded (hub mode from docker-setup.sh)
|
|
if s.isHubPreseeded() {
|
|
customerID := s.state.GetFormField("customer_id")
|
|
password := s.state.GetFormField("retrieval_password")
|
|
s.autoProcessHubRestore(w, r, customerID, password)
|
|
return
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"CustomerID": s.state.GetFormField("customer_id"),
|
|
}
|
|
s.render(w, "setup_hub_restore", data)
|
|
}
|
|
|
|
func (s *Server) handleRestore(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Redirect(w, r, "/setup", http.StatusFound)
|
|
return
|
|
}
|
|
|
|
if !validateCSRF(r) {
|
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
|
|
source := r.FormValue("source")
|
|
switch source {
|
|
case "local":
|
|
drivePath := r.FormValue("drive_path")
|
|
go s.executeLocalRestore(drivePath)
|
|
case "hub":
|
|
go s.executeHubRestore()
|
|
default:
|
|
http.Error(w, "Invalid restore source", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
}
|
|
s.render(w, "setup_restore_exec", data)
|
|
}
|
|
|
|
func (s *Server) handleRestoreStatus(w http.ResponseWriter, r *http.Request) {
|
|
s.restoreMu.Lock()
|
|
defer s.restoreMu.Unlock()
|
|
|
|
resp := map[string]interface{}{
|
|
"running": s.restoreRunning,
|
|
"done": s.restoreDone,
|
|
"steps": s.restoreSteps,
|
|
"error": s.restoreError,
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
json.NewEncoder(w).Encode(resp)
|
|
}
|
|
|
|
func (s *Server) handleFreshHub(w http.ResponseWriter, r *http.Request) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
|
|
if r.Method == http.MethodPost {
|
|
if !validateCSRF(r) {
|
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
s.processFreshHub(w, r)
|
|
return
|
|
}
|
|
|
|
// Auto-process if credentials are pre-seeded (hub mode from docker-setup.sh)
|
|
if s.isHubPreseeded() {
|
|
customerID := s.state.GetFormField("customer_id")
|
|
password := s.state.GetFormField("retrieval_password")
|
|
s.autoProcessFreshHub(w, r, customerID, password)
|
|
return
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"CustomerID": s.state.GetFormField("customer_id"),
|
|
}
|
|
s.render(w, "setup_fresh_hub", data)
|
|
}
|
|
|
|
func (s *Server) handleManual(w http.ResponseWriter, r *http.Request) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
|
|
if r.Method == http.MethodPost {
|
|
if !validateCSRF(r) {
|
|
http.Error(w, "Invalid CSRF token", http.StatusForbidden)
|
|
return
|
|
}
|
|
s.processManual(w, r)
|
|
return
|
|
}
|
|
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"FormData": s.state.FormData,
|
|
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
|
}
|
|
s.render(w, "setup_manual", data)
|
|
}
|
|
|
|
func (s *Server) handleFailed(w http.ResponseWriter, r *http.Request) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
}
|
|
s.render(w, "setup_failed", data)
|
|
}
|
|
|
|
// --- Static Assets (reuse from web package embed) ---
|
|
|
|
func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) {
|
|
// Read the main style.css from the web package templates
|
|
cssPath := filepath.Join(filepath.Dir(s.dataDir), "..", "internal", "web", "templates", "style.css")
|
|
data, err := os.ReadFile(cssPath)
|
|
if err != nil {
|
|
// Fallback: serve minimal CSS
|
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
w.Write([]byte(minimalCSS))
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/css; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "public, max-age=3600")
|
|
w.Write(data)
|
|
}
|
|
|
|
func (s *Server) handleLogo(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "image/svg+xml")
|
|
w.Header().Set("Cache-Control", "public, max-age=86400")
|
|
fmt.Fprint(w, web.FelhomLogoSVG)
|
|
}
|
|
|
|
// --- Auto-Processing (Hub pre-seeded mode) ---
|
|
|
|
// autoProcessHubRestore calls PullRecovery with pre-seeded credentials and
|
|
// renders the confirmation page directly, skipping the manual form.
|
|
// Falls back to the form with an error message on failure.
|
|
func (s *Server) autoProcessHubRestore(w http.ResponseWriter, r *http.Request, customerID, password string) {
|
|
hubURL := DefaultHubURL
|
|
|
|
s.logger.Printf("[INFO] Setup: auto-processing hub restore for %s (pre-seeded credentials)", customerID)
|
|
|
|
recovery, err := report.PullRecovery(hubURL, customerID, password)
|
|
if err != nil {
|
|
s.logger.Printf("[WARN] Setup: auto hub restore failed: %v — falling back to form", err)
|
|
var msg string
|
|
switch {
|
|
case isError(err, report.ErrHubUnreachable):
|
|
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
|
case isError(err, report.ErrAuthFailed):
|
|
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
|
case isError(err, report.ErrNotFound):
|
|
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
|
default:
|
|
msg = fmt.Sprintf("Hiba történt: %v", err)
|
|
}
|
|
// Clear pre-seeded password so form is shown on next attempt
|
|
s.state.SetFormField("retrieval_password", "")
|
|
s.state.Save()
|
|
s.renderError(w, r, "setup_hub_restore", msg, customerID)
|
|
return
|
|
}
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML))
|
|
}
|
|
|
|
// Store recovery data in state for restore execution
|
|
s.state.SelectedBackup = &SelectedBackup{
|
|
Source: "hub",
|
|
CustomerID: customerID,
|
|
}
|
|
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
|
if recovery.HasInfraBackup && recovery.InfraBackup != nil {
|
|
ibJSON, _ := json.Marshal(recovery.InfraBackup)
|
|
s.state.SetFormField("hub_infra_backup", string(ibJSON))
|
|
s.state.SelectedBackup.Timestamp = recovery.InfraBackup.Timestamp
|
|
}
|
|
s.state.SetStep("restore-exec")
|
|
s.state.Save()
|
|
|
|
s.logger.Printf("[INFO] Setup: hub recovery received (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
|
|
|
|
// Start the restore goroutine, then render the progress page
|
|
go s.executeHubRestore()
|
|
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
}
|
|
s.render(w, "setup_restore_exec", data)
|
|
}
|
|
|
|
// autoProcessFreshHub calls PullConfig with pre-seeded credentials and
|
|
// proceeds with fresh install, skipping the manual form.
|
|
func (s *Server) autoProcessFreshHub(w http.ResponseWriter, r *http.Request, customerID, password string) {
|
|
hubURL := DefaultHubURL
|
|
|
|
s.logger.Printf("[INFO] Setup: auto-processing fresh hub install for %s (pre-seeded credentials)", customerID)
|
|
|
|
configYAML, err := report.PullConfig(hubURL, customerID, password)
|
|
if err != nil {
|
|
s.logger.Printf("[WARN] Setup: auto fresh hub failed: %v — falling back to form", err)
|
|
// Clear pre-seeded password so form is shown on next attempt
|
|
s.state.SetFormField("retrieval_password", "")
|
|
s.state.Save()
|
|
var msg string
|
|
switch {
|
|
case isError(err, report.ErrHubUnreachable):
|
|
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
|
case isError(err, report.ErrAuthFailed):
|
|
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
|
case isError(err, report.ErrNotFound):
|
|
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
|
default:
|
|
msg = fmt.Sprintf("Hiba történt: %v", err)
|
|
}
|
|
s.renderError(w, r, "setup_fresh_hub", msg, customerID)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Setup: config downloaded (%d bytes), writing config...", len(configYAML))
|
|
|
|
if err := s.writeFreshConfig(configYAML, password); err != nil {
|
|
s.logger.Printf("[ERROR] Setup: writeFreshConfig failed: %v", err)
|
|
s.renderError(w, r, "setup_fresh_hub", fmt.Sprintf("Konfigurációs hiba: %v", err), customerID)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Setup: fresh install from Hub completed for %s", customerID)
|
|
s.finishSetup()
|
|
}
|
|
|
|
// --- Processing Logic ---
|
|
|
|
func (s *Server) processHubRestore(w http.ResponseWriter, r *http.Request) {
|
|
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
|
password := r.FormValue("password")
|
|
hubURL := DefaultHubURL
|
|
|
|
s.state.SetFormField("customer_id", customerID)
|
|
|
|
if customerID == "" || password == "" {
|
|
s.renderError(w, r, "setup_hub_restore", "Kérem töltse ki mindkét mezőt.", customerID)
|
|
return
|
|
}
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] Setup: hub restore — pulling recovery from %s for customer %s", hubURL, customerID)
|
|
}
|
|
recovery, err := report.PullRecovery(hubURL, customerID, password)
|
|
if err != nil {
|
|
var msg string
|
|
switch {
|
|
case isError(err, report.ErrHubUnreachable):
|
|
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
|
case isError(err, report.ErrAuthFailed):
|
|
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
|
case isError(err, report.ErrNotFound):
|
|
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
|
default:
|
|
msg = fmt.Sprintf("Hiba történt: %v", err)
|
|
}
|
|
s.renderError(w, r, "setup_hub_restore", msg, customerID)
|
|
return
|
|
}
|
|
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] Setup: hub recovery received — hasInfra=%v, configLen=%d", recovery.HasInfraBackup, len(recovery.ConfigYAML))
|
|
}
|
|
|
|
// Store recovery data in state for restore execution
|
|
s.state.SelectedBackup = &SelectedBackup{
|
|
Source: "hub",
|
|
CustomerID: customerID,
|
|
}
|
|
s.state.SetFormField("retrieval_password", password)
|
|
s.state.SetFormField("hub_config_yaml", recovery.ConfigYAML)
|
|
if recovery.HasInfraBackup && recovery.InfraBackup != nil {
|
|
ibJSON, _ := json.Marshal(recovery.InfraBackup)
|
|
s.state.SetFormField("hub_infra_backup", string(ibJSON))
|
|
s.state.SelectedBackup.Timestamp = recovery.InfraBackup.Timestamp
|
|
}
|
|
s.state.SetStep("restore-exec")
|
|
s.state.Save()
|
|
|
|
s.logger.Printf("[INFO] Setup: hub recovery received (hasInfra=%v) — starting restore", recovery.HasInfraBackup)
|
|
|
|
// Start the restore goroutine, then render the progress page
|
|
go s.executeHubRestore()
|
|
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
}
|
|
s.render(w, "setup_restore_exec", data)
|
|
}
|
|
|
|
func (s *Server) processFreshHub(w http.ResponseWriter, r *http.Request) {
|
|
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
|
password := r.FormValue("password")
|
|
hubURL := DefaultHubURL
|
|
|
|
s.state.SetFormField("customer_id", customerID)
|
|
|
|
if customerID == "" || password == "" {
|
|
s.renderError(w, r, "setup_fresh_hub", "Kérem töltse ki mindkét mezőt.", customerID)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Setup: downloading config from Hub (%s) for customer %s", hubURL, customerID)
|
|
configYAML, err := report.PullConfig(hubURL, customerID, password)
|
|
if err != nil {
|
|
s.logger.Printf("[ERROR] Setup: Hub config download failed: %v", err)
|
|
var msg string
|
|
switch {
|
|
case isError(err, report.ErrHubUnreachable):
|
|
msg = "A Hub (hub.felhom.eu) nem elérhető. Ellenőrizze az internetkapcsolatot."
|
|
case isError(err, report.ErrAuthFailed):
|
|
msg = "Helytelen ügyfél-azonosító vagy jelszó."
|
|
case isError(err, report.ErrNotFound):
|
|
msg = "Ez az ügyfél-azonosító nem található a Hub-on."
|
|
default:
|
|
msg = fmt.Sprintf("Hiba történt: %v", err)
|
|
}
|
|
s.renderError(w, r, "setup_fresh_hub", msg, customerID)
|
|
return
|
|
}
|
|
s.logger.Printf("[INFO] Setup: config downloaded (%d bytes), writing config...", len(configYAML))
|
|
|
|
// Write config and finish setup
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] Setup: writing fresh config (%d bytes)", len(configYAML))
|
|
}
|
|
s.state.SetFormField("retrieval_password", password)
|
|
if err := s.writeFreshConfig(configYAML, password); err != nil {
|
|
s.logger.Printf("[ERROR] Setup: writeFreshConfig failed: %v", err)
|
|
s.renderError(w, r, "setup_fresh_hub", fmt.Sprintf("Konfigurációs hiba: %v", err), customerID)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Setup: fresh install from Hub completed for %s", customerID)
|
|
s.finishSetup()
|
|
}
|
|
|
|
func (s *Server) processManual(w http.ResponseWriter, r *http.Request) {
|
|
// Save all form fields
|
|
fields := []string{"customer_id", "display_name", "domain", "email",
|
|
"cf_tunnel_token", "cf_api_token", "system_data_path",
|
|
"password", "password_confirm",
|
|
"git_repo_url", "git_username", "git_token"}
|
|
for _, f := range fields {
|
|
s.state.SetFormField(f, r.FormValue(f))
|
|
}
|
|
|
|
// Validate
|
|
customerID := strings.TrimSpace(r.FormValue("customer_id"))
|
|
domain := strings.TrimSpace(r.FormValue("domain"))
|
|
password := r.FormValue("password")
|
|
passwordConfirm := r.FormValue("password_confirm")
|
|
|
|
var errs []string
|
|
if customerID == "" {
|
|
errs = append(errs, "Ügyfél-azonosító kötelező")
|
|
}
|
|
if domain == "" || domain == "homeserver.local" {
|
|
errs = append(errs, "Érvényes domain szükséges")
|
|
}
|
|
if password != "" && len(password) < 8 {
|
|
errs = append(errs, "A jelszó legalább 8 karakter legyen")
|
|
}
|
|
if password != passwordConfirm {
|
|
errs = append(errs, "A jelszavak nem egyeznek")
|
|
}
|
|
|
|
if len(errs) > 0 {
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"FormData": s.state.FormData,
|
|
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
|
"Errors": errs,
|
|
}
|
|
s.render(w, "setup_manual", data)
|
|
return
|
|
}
|
|
|
|
// Generate controller.yaml
|
|
configYAML := s.generateManualConfig()
|
|
if s.isDebug() {
|
|
s.logger.Printf("[DEBUG] Setup: generated manual config (%d bytes) for customer %s", len(configYAML), customerID)
|
|
}
|
|
if err := s.writeFreshConfig(configYAML, ""); err != nil {
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"FormData": s.state.FormData,
|
|
"DefaultGit": "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git",
|
|
"Errors": []string{fmt.Sprintf("Konfigurációs hiba: %v", err)},
|
|
}
|
|
s.render(w, "setup_manual", data)
|
|
return
|
|
}
|
|
|
|
s.logger.Printf("[INFO] Setup: manual configuration completed for %s", customerID)
|
|
s.finishSetup()
|
|
}
|
|
|
|
// --- Restore Execution ---
|
|
|
|
func (s *Server) executeLocalRestore(drivePath string) {
|
|
s.restoreMu.Lock()
|
|
s.restoreRunning = true
|
|
s.restoreDone = false
|
|
s.restoreError = ""
|
|
s.restoreSteps = []RestoreStep{
|
|
{Label: "Mentés beolvasása...", Status: "running"},
|
|
{Label: "Konfiguráció visszaállítása...", Status: "pending"},
|
|
{Label: "Beállítás befejezése...", Status: "pending"},
|
|
}
|
|
s.restoreMu.Unlock()
|
|
|
|
// Step 1: Read backup
|
|
backupData, _, err := backup.ReadLocalInfraBackup(drivePath)
|
|
if err != nil {
|
|
s.setRestoreError(0, fmt.Sprintf("Mentés olvasási hiba: %v", err))
|
|
return
|
|
}
|
|
|
|
var ib report.InfraBackup
|
|
if err := json.Unmarshal(backupData, &ib); err != nil {
|
|
s.setRestoreError(0, fmt.Sprintf("Mentés formátum hiba: %v", err))
|
|
return
|
|
}
|
|
s.setRestoreStepDone(0)
|
|
|
|
// Step 2: Write config files
|
|
s.setRestoreStepRunning(1)
|
|
if err := s.writeRestoredConfig(&ib); err != nil {
|
|
s.setRestoreError(1, fmt.Sprintf("Konfiguráció írási hiba: %v", err))
|
|
return
|
|
}
|
|
s.setRestoreStepDone(1)
|
|
|
|
// Step 3: Finalize
|
|
s.setRestoreStepRunning(2)
|
|
|
|
// Save retrieval password from state if available
|
|
retrievalPw := s.state.GetFormField("retrieval_password")
|
|
if retrievalPw != "" {
|
|
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
|
if err == nil {
|
|
sett.SetRetrievalPassword(retrievalPw)
|
|
}
|
|
}
|
|
|
|
// Queue DR event
|
|
s.queueDREvent("local", ib.Timestamp, len(ib.DeployedStacks))
|
|
|
|
s.setRestoreStepDone(2)
|
|
|
|
s.restoreMu.Lock()
|
|
s.restoreRunning = false
|
|
s.restoreDone = true
|
|
s.restoreMu.Unlock()
|
|
|
|
s.logger.Printf("[INFO] Setup: local restore completed from %s", drivePath)
|
|
|
|
// Wait a moment for the UI to poll, then exit
|
|
time.Sleep(2 * time.Second)
|
|
s.finishSetup()
|
|
}
|
|
|
|
func (s *Server) executeHubRestore() {
|
|
s.restoreMu.Lock()
|
|
s.restoreRunning = true
|
|
s.restoreDone = false
|
|
s.restoreError = ""
|
|
s.restoreSteps = []RestoreStep{
|
|
{Label: "Konfiguráció visszaállítása...", Status: "running"},
|
|
{Label: "Beállítás befejezése...", Status: "pending"},
|
|
}
|
|
s.restoreMu.Unlock()
|
|
|
|
// Get stored data from state
|
|
configYAML := s.state.GetFormField("hub_config_yaml")
|
|
ibJSON := s.state.GetFormField("hub_infra_backup")
|
|
|
|
// Write controller.yaml
|
|
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
|
if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil {
|
|
s.setRestoreError(0, fmt.Sprintf("Konfiguráció írási hiba: %v", err))
|
|
return
|
|
}
|
|
|
|
// Restore settings from infra backup if available
|
|
if ibJSON != "" {
|
|
var ib report.InfraBackup
|
|
if err := json.Unmarshal([]byte(ibJSON), &ib); err == nil {
|
|
s.restoreFromInfraBackup(&ib)
|
|
}
|
|
}
|
|
s.setRestoreStepDone(0)
|
|
|
|
// Step 2: Finalize
|
|
s.setRestoreStepRunning(1)
|
|
|
|
// Save retrieval password
|
|
retrievalPw := s.state.GetFormField("retrieval_password")
|
|
if retrievalPw != "" {
|
|
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
|
if err == nil {
|
|
sett.SetRetrievalPassword(retrievalPw)
|
|
}
|
|
}
|
|
|
|
// Queue DR event
|
|
stackCount := 0
|
|
timestamp := ""
|
|
if ibJSON != "" {
|
|
var ib report.InfraBackup
|
|
if json.Unmarshal([]byte(ibJSON), &ib) == nil {
|
|
stackCount = len(ib.DeployedStacks)
|
|
timestamp = ib.Timestamp
|
|
}
|
|
}
|
|
s.queueDREvent("hub", timestamp, stackCount)
|
|
|
|
s.setRestoreStepDone(1)
|
|
|
|
s.restoreMu.Lock()
|
|
s.restoreRunning = false
|
|
s.restoreDone = true
|
|
s.restoreMu.Unlock()
|
|
|
|
s.logger.Printf("[INFO] Setup: Hub restore completed")
|
|
|
|
time.Sleep(2 * time.Second)
|
|
s.finishSetup()
|
|
}
|
|
|
|
// --- Config Writing ---
|
|
|
|
func (s *Server) writeRestoredConfig(ib *report.InfraBackup) error {
|
|
// Decode and write controller.yaml
|
|
if ib.ControllerConfigB64 != "" {
|
|
configData, err := base64.StdEncoding.DecodeString(ib.ControllerConfigB64)
|
|
if err != nil {
|
|
return fmt.Errorf("decoding controller.yaml: %w", err)
|
|
}
|
|
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
|
if err := atomicWriteFile(configPath, configData, 0600); err != nil {
|
|
return fmt.Errorf("writing controller.yaml: %w", err)
|
|
}
|
|
}
|
|
|
|
s.restoreFromInfraBackup(ib)
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) restoreFromInfraBackup(ib *report.InfraBackup) {
|
|
// Decode and write settings.json
|
|
if ib.SettingsJSONB64 != "" {
|
|
if data, err := base64.StdEncoding.DecodeString(ib.SettingsJSONB64); err == nil {
|
|
settingsPath := filepath.Join(s.dataDir, "settings.json")
|
|
if err := atomicWriteFile(settingsPath, data, 0644); err != nil {
|
|
s.logger.Printf("[WARN] Setup: failed to restore settings.json: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore restic password
|
|
if ib.ResticPassword != "" {
|
|
if data, err := base64.StdEncoding.DecodeString(ib.ResticPassword); err == nil {
|
|
pwFile := "/opt/docker/felhom-controller/data/restic-password"
|
|
if err := atomicWriteFile(pwFile, data, 0600); err != nil {
|
|
s.logger.Printf("[WARN] Setup: failed to restore restic password: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Restore encryption key for app.yaml secrets
|
|
if ib.EncryptionKeyB64 != "" {
|
|
if data, err := base64.StdEncoding.DecodeString(ib.EncryptionKeyB64); err == nil {
|
|
keyFile := filepath.Join(s.dataDir, "encryption.key")
|
|
if err := atomicWriteFile(keyFile, data, 0600); err != nil {
|
|
s.logger.Printf("[WARN] Setup: failed to restore encryption key: %v", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *Server) writeFreshConfig(configYAML, retrievalPassword string) error {
|
|
configPath := "/opt/docker/felhom-controller/controller.yaml"
|
|
if err := atomicWriteFile(configPath, []byte(configYAML), 0600); err != nil {
|
|
return fmt.Errorf("writing controller.yaml: %w", err)
|
|
}
|
|
|
|
// Create initial settings with password hash and retrieval password
|
|
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
|
if err != nil {
|
|
sett = &settings.Settings{}
|
|
}
|
|
|
|
// Hash the dashboard password if provided in form
|
|
if pw := s.state.GetFormField("password"); pw != "" {
|
|
hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost)
|
|
if err == nil {
|
|
sett.SetPasswordHash(string(hash))
|
|
}
|
|
}
|
|
|
|
if retrievalPassword != "" {
|
|
sett.SetRetrievalPassword(retrievalPassword)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) generateManualConfig() string {
|
|
fd := s.state.FormData
|
|
|
|
customerID := fd["customer_id"]
|
|
displayName := fd["display_name"]
|
|
if displayName == "" {
|
|
displayName = customerID
|
|
}
|
|
domain := fd["domain"]
|
|
email := fd["email"]
|
|
cfTunnelToken := fd["cf_tunnel_token"]
|
|
cfAPIToken := fd["cf_api_token"]
|
|
systemDataPath := fd["system_data_path"]
|
|
if systemDataPath == "" {
|
|
systemDataPath = "/mnt/sys_drive"
|
|
}
|
|
|
|
// Generate session secret
|
|
secretBytes := make([]byte, 32)
|
|
crand.Read(secretBytes)
|
|
sessionSecret := hex.EncodeToString(secretBytes)
|
|
|
|
// Generate password hash
|
|
passwordHash := ""
|
|
if pw := fd["password"]; pw != "" {
|
|
if hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost); err == nil {
|
|
passwordHash = string(hash)
|
|
}
|
|
}
|
|
|
|
gitRepoURL := fd["git_repo_url"]
|
|
if gitRepoURL == "" {
|
|
gitRepoURL = "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git"
|
|
}
|
|
gitUsername := fd["git_username"]
|
|
gitToken := fd["git_token"]
|
|
|
|
// Build YAML manually (simple key-value, no templates needed)
|
|
var b strings.Builder
|
|
b.WriteString("# Generated by felhom-controller setup wizard\n")
|
|
b.WriteString("customer:\n")
|
|
fmt.Fprintf(&b, " id: %q\n", customerID)
|
|
fmt.Fprintf(&b, " name: %q\n", displayName)
|
|
fmt.Fprintf(&b, " domain: %q\n", domain)
|
|
if email != "" {
|
|
fmt.Fprintf(&b, " email: %q\n", email)
|
|
}
|
|
b.WriteString("\ninfrastructure:\n")
|
|
if cfTunnelToken != "" {
|
|
fmt.Fprintf(&b, " cf_tunnel_token: %q\n", cfTunnelToken)
|
|
}
|
|
if cfAPIToken != "" {
|
|
fmt.Fprintf(&b, " cf_api_token: %q\n", cfAPIToken)
|
|
}
|
|
b.WriteString("\npaths:\n")
|
|
b.WriteString(" stacks_dir: \"/opt/docker/stacks\"\n")
|
|
b.WriteString(" data_dir: \"/opt/docker/felhom-controller/data\"\n")
|
|
fmt.Fprintf(&b, " system_data_path: %q\n", systemDataPath)
|
|
b.WriteString("\nsystem:\n")
|
|
b.WriteString(" reserved_memory_mb: 384\n")
|
|
b.WriteString("\nweb:\n")
|
|
b.WriteString(" listen: \":8080\"\n")
|
|
b.WriteString(" setup_listen: \":8081\"\n")
|
|
if passwordHash != "" {
|
|
fmt.Fprintf(&b, " password_hash: %q\n", passwordHash)
|
|
}
|
|
fmt.Fprintf(&b, " session_secret: %q\n", sessionSecret)
|
|
b.WriteString("\ngit:\n")
|
|
fmt.Fprintf(&b, " repo_url: %q\n", gitRepoURL)
|
|
b.WriteString(" branch: \"main\"\n")
|
|
b.WriteString(" sync_interval: \"15m\"\n")
|
|
if gitUsername != "" {
|
|
fmt.Fprintf(&b, " username: %q\n", gitUsername)
|
|
}
|
|
if gitToken != "" {
|
|
fmt.Fprintf(&b, " token: %q\n", gitToken)
|
|
}
|
|
b.WriteString("\nstacks:\n")
|
|
b.WriteString(" protected:\n")
|
|
b.WriteString(" - \"traefik\"\n")
|
|
b.WriteString(" - \"cloudflared\"\n")
|
|
b.WriteString(" - \"felhom-controller\"\n")
|
|
b.WriteString(" - \"filebrowser\"\n")
|
|
b.WriteString(" update_window: \"03:00-05:00\"\n")
|
|
b.WriteString("\nbackup:\n")
|
|
b.WriteString(" enabled: true\n")
|
|
b.WriteString(" restic_password_file: \"/opt/docker/felhom-controller/data/restic-password\"\n")
|
|
b.WriteString(" db_dump_schedule: \"02:30\"\n")
|
|
b.WriteString(" restic_schedule: \"03:00\"\n")
|
|
b.WriteString(" retention:\n")
|
|
b.WriteString(" keep_daily: 7\n")
|
|
b.WriteString(" keep_weekly: 4\n")
|
|
b.WriteString(" keep_monthly: 6\n")
|
|
b.WriteString(" prune_schedule: \"weekly\"\n")
|
|
b.WriteString("\nmonitoring:\n")
|
|
b.WriteString(" enabled: true\n")
|
|
b.WriteString(" healthchecks_base: \"https://status.felhom.eu\"\n")
|
|
b.WriteString(" system_health_interval: \"5m\"\n")
|
|
b.WriteString(" health_check_schedule: \"06:00\"\n")
|
|
b.WriteString("\nhub:\n")
|
|
b.WriteString(" enabled: true\n")
|
|
b.WriteString(" url: \"https://hub.felhom.eu\"\n")
|
|
// Generate a Hub API key from customer ID
|
|
apiKeyHash := sha256.Sum256([]byte(customerID + "-" + sessionSecret))
|
|
fmt.Fprintf(&b, " api_key: %q\n", hex.EncodeToString(apiKeyHash[:]))
|
|
b.WriteString(" push_interval: \"15m\"\n")
|
|
b.WriteString("\nself_update:\n")
|
|
b.WriteString(" enabled: true\n")
|
|
b.WriteString(" check_interval: \"6h\"\n")
|
|
b.WriteString(" image: \"gitea.dooplex.hu/admin/felhom-controller\"\n")
|
|
b.WriteString(" auto_update: false\n")
|
|
b.WriteString(" health_timeout_seconds: 60\n")
|
|
b.WriteString("\nlogging:\n")
|
|
b.WriteString(" level: \"info\"\n")
|
|
|
|
return b.String()
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
func (s *Server) finishSetup() {
|
|
s.state.Remove()
|
|
s.logger.Printf("[INFO] Setup complete — restarting controller")
|
|
os.Exit(0) // Docker restart policy will restart us
|
|
}
|
|
|
|
func (s *Server) queueDREvent(source, backupTimestamp string, stackCount int) {
|
|
sett, err := settings.Load(filepath.Join(s.dataDir, "settings.json"), s.logger)
|
|
if err != nil {
|
|
s.logger.Printf("[WARN] Setup: failed to load settings for DR event: %v", err)
|
|
return
|
|
}
|
|
|
|
details, _ := json.Marshal(map[string]interface{}{
|
|
"source": source,
|
|
"backup_timestamp": backupTimestamp,
|
|
"stacks_count": stackCount,
|
|
"controller_version": s.version,
|
|
})
|
|
|
|
sett.AddPendingEvent(settings.PendingEvent{
|
|
EventType: "disaster_recovery_completed",
|
|
Severity: "warning",
|
|
Message: "System restored from backup",
|
|
Details: string(details),
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
}
|
|
|
|
func (s *Server) setRestoreStepDone(idx int) {
|
|
s.restoreMu.Lock()
|
|
defer s.restoreMu.Unlock()
|
|
if idx < len(s.restoreSteps) {
|
|
s.restoreSteps[idx].Status = "done"
|
|
}
|
|
}
|
|
|
|
func (s *Server) setRestoreStepRunning(idx int) {
|
|
s.restoreMu.Lock()
|
|
defer s.restoreMu.Unlock()
|
|
if idx < len(s.restoreSteps) {
|
|
s.restoreSteps[idx].Status = "running"
|
|
}
|
|
}
|
|
|
|
func (s *Server) setRestoreError(idx int, msg string) {
|
|
s.restoreMu.Lock()
|
|
defer s.restoreMu.Unlock()
|
|
if idx < len(s.restoreSteps) {
|
|
s.restoreSteps[idx].Status = "failed"
|
|
s.restoreSteps[idx].Error = msg
|
|
}
|
|
s.restoreRunning = false
|
|
s.restoreError = msg
|
|
}
|
|
|
|
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 %s render error: %v", name, err)
|
|
http.Error(w, "Internal error", http.StatusInternalServerError)
|
|
}
|
|
}
|
|
|
|
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, tmpl, msg, customerID string) {
|
|
csrf := ensureCSRFToken(w, r)
|
|
data := map[string]interface{}{
|
|
"CSRF": csrf,
|
|
"Error": msg,
|
|
"CustomerID": customerID,
|
|
}
|
|
s.render(w, tmpl, data)
|
|
}
|
|
|
|
func atomicWriteFile(path string, data []byte, perm os.FileMode) error {
|
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
|
return err
|
|
}
|
|
tmp := path + ".tmp"
|
|
if err := os.WriteFile(tmp, data, perm); err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
if err := os.Rename(tmp, path); err != nil {
|
|
os.Remove(tmp)
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func isError(err, target error) bool {
|
|
return err != nil && strings.Contains(err.Error(), target.Error())
|
|
}
|
|
|
|
// Minimal CSS for when the main stylesheet can't be loaded
|
|
const minimalCSS = `
|
|
:root { --bg-primary: #0d1117; --bg-card: #1c2128; --text-primary: #e6edf3; --text-secondary: #8b949e; --accent-blue: #0088cc; --border: #30363d; --green: #238636; --red: #da3633; }
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
|
.setup-container { max-width: 700px; margin: 0 auto; padding: 2rem 1.5rem; }
|
|
.setup-header { text-align: center; margin-bottom: 2rem; }
|
|
.setup-header img { width: 120px; margin-bottom: 1rem; }
|
|
.setup-header h1 { font-size: 1.5rem; margin-bottom: 0.5rem; }
|
|
.setup-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: 8px; padding: 1.5rem; margin-bottom: 1rem; cursor: pointer; transition: border-color 0.2s; }
|
|
.setup-card:hover { border-color: var(--accent-blue); }
|
|
.setup-card h3 { margin-bottom: 0.5rem; }
|
|
.setup-card p { color: var(--text-secondary); font-size: 0.9rem; }
|
|
.form-group { margin-bottom: 1rem; }
|
|
.form-group label { display: block; margin-bottom: 0.25rem; font-size: 0.9rem; color: var(--text-secondary); }
|
|
.form-control { width: 100%; padding: 0.5rem 0.75rem; background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary); font-size: 0.9rem; }
|
|
.btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.6rem 1.5rem; border-radius: 6px; border: none; font-size: 0.9rem; font-weight: 500; cursor: pointer; text-decoration: none; }
|
|
.btn-primary { background: var(--green); color: #fff; }
|
|
.btn-primary:hover { background: #2ea043; }
|
|
.btn-outline { background: transparent; color: var(--text-secondary); border: 1px solid var(--border); }
|
|
.alert { padding: 0.75rem 1rem; border-radius: 6px; margin-bottom: 1rem; font-size: 0.9rem; }
|
|
.alert-error { background: rgba(218,54,51,0.15); color: #f85149; border: 1px solid rgba(218,54,51,0.3); }
|
|
.alert-info { background: rgba(0,136,204,0.15); color: #58a6ff; border: 1px solid rgba(0,136,204,0.3); }
|
|
.info-box { background: rgba(0,136,204,0.1); border: 1px solid rgba(0,136,204,0.2); border-radius: 6px; padding: 0.75rem 1rem; margin-bottom: 1.5rem; font-size: 0.85rem; color: var(--text-secondary); }
|
|
table { width: 100%; border-collapse: collapse; }
|
|
th { text-align: left; padding: 0.5rem 0.75rem; color: var(--text-secondary); font-size: 0.85rem; border-bottom: 1px solid var(--border); }
|
|
td { padding: 0.6rem 0.75rem; border-bottom: 1px solid var(--border); font-size: 0.9rem; }
|
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 0.75rem; }
|
|
.badge-ok { background: rgba(63,185,80,0.15); color: var(--green); }
|
|
.badge-error { background: rgba(218,54,51,0.15); color: var(--red); }
|
|
.spinner { display: inline-block; width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--accent-blue); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
.section { margin-bottom: 1.5rem; }
|
|
.section-header { cursor: pointer; padding: 0.75rem 1rem; background: var(--bg-card); border: 1px solid var(--border); border-radius: 6px; display: flex; justify-content: space-between; align-items: center; }
|
|
.section-body { padding: 1rem; border: 1px solid var(--border); border-top: none; border-radius: 0 0 6px 6px; }
|
|
.step-list { list-style: none; }
|
|
.step-list li { padding: 0.5rem 0; display: flex; align-items: center; gap: 0.75rem; }
|
|
.step-done { color: var(--green); }
|
|
.step-running { color: var(--accent-blue); }
|
|
.step-failed { color: var(--red); }
|
|
`
|