package web import ( "encoding/json" "fmt" "net/http" "regexp" "sort" "strings" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/configgen" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) var validCustomerID = regexp.MustCompile(`^[a-zA-Z0-9.\-]+$`) // customerListEntry is a merged view of a customer from both configs and reports. type customerListEntry struct { CustomerID string CustomerName string Domain string HasConfig bool OverallStatus string // ok, warn, down, disabled, "" if no reports ControllerVersion string TimeSinceReport time.Duration ConfigCreatedAt time.Time } // handleConfigList shows all customers (merged from configs + reports). func (s *Server) handleConfigList(w http.ResponseWriter, r *http.Request) { configs, err := s.store.ListCustomerConfigs() if err != nil { s.logger.Printf("[ERROR] Failed to list configs: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } customers, err := s.store.GetCustomers() if err != nil { s.logger.Printf("[ERROR] Failed to list customers: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } // Build merged map keyed by customer_id merged := make(map[string]*customerListEntry) for _, cfg := range configs { merged[cfg.CustomerID] = &customerListEntry{ CustomerID: cfg.CustomerID, CustomerName: cfg.CustomerName, Domain: cfg.Domain, HasConfig: true, ConfigCreatedAt: cfg.CreatedAt, } } for _, c := range customers { status := "ok" if c.HealthStatus == "disabled" { status = "disabled" } else if c.TimeSinceReport > time.Hour { status = "down" } else if c.TimeSinceReport > 30*time.Minute || c.HealthStatus == "warn" { status = "warn" } else if c.HealthStatus == "fail" { status = "down" } if entry, ok := merged[c.CustomerID]; ok { // Config exists — enrich with report data entry.OverallStatus = status entry.ControllerVersion = c.ControllerVersion entry.TimeSinceReport = c.TimeSinceReport if entry.CustomerName == "" { entry.CustomerName = c.CustomerName } } else { // Report-only customer (no config) merged[c.CustomerID] = &customerListEntry{ CustomerID: c.CustomerID, CustomerName: c.CustomerName, OverallStatus: status, ControllerVersion: c.ControllerVersion, TimeSinceReport: c.TimeSinceReport, } } } // Sort by customer_id entries := make([]customerListEntry, 0, len(merged)) for _, e := range merged { entries = append(entries, *e) } sort.Slice(entries, func(i, j int) bool { return entries[i].CustomerID < entries[j].CustomerID }) data := struct { Customers []customerListEntry ActiveNav string Flash string }{ Customers: entries, ActiveNav: "configs", Flash: r.URL.Query().Get("flash"), } s.templates.ExecuteTemplate(w, "configs.html", data) } // handleConfigNewForm shows the form to create a new customer config. func (s *Server) handleConfigNewForm(w http.ResponseWriter, r *http.Request) { data := struct { IsNew bool Config *store.CustomerConfig Overrides map[string]interface{} ActiveNav string Error string }{ IsNew: true, Config: &store.CustomerConfig{}, Overrides: make(map[string]interface{}), ActiveNav: "configs", } s.templates.ExecuteTemplate(w, "config_form.html", data) } // handleConfigCreate processes the form submission to create a new config. func (s *Server) handleConfigCreate(w http.ResponseWriter, r *http.Request) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } customerID := strings.TrimSpace(r.FormValue("customer_id")) if customerID == "" || !validCustomerID.MatchString(customerID) { s.renderConfigForm(w, true, &store.CustomerConfig{ CustomerName: r.FormValue("customer_name"), Domain: r.FormValue("domain"), Email: r.FormValue("email"), }, nil, "Invalid Customer ID. Use only letters, numbers, dots, and hyphens.") return } // Check for duplicates existing, _ := s.store.GetCustomerConfig(customerID) if existing != nil { s.renderConfigForm(w, true, &store.CustomerConfig{ CustomerID: customerID, CustomerName: r.FormValue("customer_name"), Domain: r.FormValue("domain"), Email: r.FormValue("email"), }, nil, fmt.Sprintf("Customer ID %q already exists.", customerID)) return } // Generate credentials retrievalPassword, err := configgen.RandomHex(32) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } apiKey, err := configgen.RandomHex(32) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } // Build config_json from optional form fields configJSON := buildConfigJSON(r) cfg := &store.CustomerConfig{ CustomerID: customerID, CustomerName: strings.TrimSpace(r.FormValue("customer_name")), Domain: strings.TrimSpace(r.FormValue("domain")), Email: strings.TrimSpace(r.FormValue("email")), RetrievalPassword: retrievalPassword, APIKey: apiKey, ConfigJSON: configJSON, } if err := s.store.SaveCustomerConfig(cfg); err != nil { s.logger.Printf("[ERROR] Failed to save config for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer config created: %s", customerID) http.Redirect(w, r, "/configs/"+customerID+"?flash=created", http.StatusSeeOther) } // handleConfigDetail shows a customer config with credentials and setup commands. func (s *Server) handleConfigDetail(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil { s.logger.Printf("[ERROR] Failed to get config %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if cfg == nil { http.NotFound(w, r) return } // Parse config_json for display var overrides map[string]interface{} json.Unmarshal([]byte(cfg.ConfigJSON), &overrides) data := struct { Config *store.CustomerConfig Overrides map[string]interface{} ActiveNav string Flash string }{ Config: cfg, Overrides: overrides, ActiveNav: "configs", Flash: r.URL.Query().Get("flash"), } s.templates.ExecuteTemplate(w, "config_detail.html", data) } // handleConfigEditForm shows the edit form for a customer config. func (s *Server) handleConfigEditForm(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } var overrides map[string]interface{} json.Unmarshal([]byte(cfg.ConfigJSON), &overrides) data := struct { IsNew bool Config *store.CustomerConfig Overrides map[string]interface{} ActiveNav string Error string }{ IsNew: false, Config: cfg, Overrides: overrides, ActiveNav: "configs", } s.templates.ExecuteTemplate(w, "config_form.html", data) } // handleConfigUpdate processes the edit form submission. func (s *Server) handleConfigUpdate(w http.ResponseWriter, r *http.Request, customerID string) { if err := r.ParseForm(); err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return } cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } cfg.CustomerName = strings.TrimSpace(r.FormValue("customer_name")) cfg.Domain = strings.TrimSpace(r.FormValue("domain")) cfg.Email = strings.TrimSpace(r.FormValue("email")) cfg.ConfigJSON = buildConfigJSON(r) if err := s.store.SaveCustomerConfig(cfg); err != nil { s.logger.Printf("[ERROR] Failed to update config for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer config updated: %s", customerID) http.Redirect(w, r, "/configs/"+customerID+"?flash=updated", http.StatusSeeOther) } // handleConfigDelete deletes a customer config. func (s *Server) handleConfigDelete(w http.ResponseWriter, r *http.Request, customerID string) { if err := s.store.DeleteCustomerConfig(customerID); err != nil { s.logger.Printf("[ERROR] Failed to delete config %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Customer config deleted: %s", customerID) http.Redirect(w, r, "/configs?flash=deleted", http.StatusSeeOther) } // handleConfigPreview returns the generated YAML for a customer config. func (s *Server) handleConfigPreview(w http.ResponseWriter, r *http.Request, customerID string) { cfg, err := s.store.GetCustomerConfig(customerID) if err != nil || cfg == nil { http.NotFound(w, r) return } templateYAML := defaultControllerTemplate if s.templateFetcher != nil { templateYAML = s.templateFetcher.Template() } yamlOutput, err := configgen.Generate(templateYAML, cfg) if err != nil { s.logger.Printf("[ERROR] Failed to generate preview for %s: %v", customerID, err) http.Error(w, "Generation error: "+err.Error(), http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/yaml; charset=utf-8") w.Write([]byte(yamlOutput)) } // handleConfigRegenPassword regenerates the retrieval password. func (s *Server) handleConfigRegenPassword(w http.ResponseWriter, r *http.Request, customerID string) { newPassword, err := configgen.RandomHex(32) if err != nil { http.Error(w, "Internal error", http.StatusInternalServerError) return } if err := s.store.UpdateRetrievalPassword(customerID, newPassword); err != nil { s.logger.Printf("[ERROR] Failed to regen password for %s: %v", customerID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return } s.logger.Printf("[INFO] Retrieval password regenerated for %s", customerID) http.Redirect(w, r, "/configs/"+customerID+"?flash=password_regenerated", http.StatusSeeOther) } // renderConfigForm is a helper to re-render the form with an error. func (s *Server) renderConfigForm(w http.ResponseWriter, isNew bool, cfg *store.CustomerConfig, overrides map[string]interface{}, errMsg string) { if overrides == nil { overrides = make(map[string]interface{}) } data := struct { IsNew bool Config *store.CustomerConfig Overrides map[string]interface{} ActiveNav string Error string }{ IsNew: isNew, Config: cfg, Overrides: overrides, ActiveNav: "configs", Error: errMsg, } s.templates.ExecuteTemplate(w, "config_form.html", data) } // buildConfigJSON builds the config_json from optional form fields. func buildConfigJSON(r *http.Request) string { overrides := make(map[string]interface{}) // Infrastructure infra := make(map[string]interface{}) if v := strings.TrimSpace(r.FormValue("cf_tunnel_token")); v != "" { infra["cf_tunnel_token"] = v } if v := strings.TrimSpace(r.FormValue("cf_api_token")); v != "" { infra["cf_api_token"] = v } if len(infra) > 0 { overrides["infrastructure"] = infra } // Git git := make(map[string]interface{}) if v := strings.TrimSpace(r.FormValue("git_username")); v != "" { git["username"] = v } if v := strings.TrimSpace(r.FormValue("git_token")); v != "" { git["token"] = v } if len(git) > 0 { overrides["git"] = git } // Monitoring UUIDs uuids := make(map[string]interface{}) for _, key := range []string{"heartbeat", "system_health", "db_dump", "backup", "backup_integrity"} { if v := strings.TrimSpace(r.FormValue("uuid_" + key)); v != "" { uuids[key] = v } } if len(uuids) > 0 { if _, ok := overrides["monitoring"]; !ok { overrides["monitoring"] = make(map[string]interface{}) } overrides["monitoring"].(map[string]interface{})["ping_uuids"] = uuids } data, _ := json.Marshal(overrides) return string(data) } // getNestedString is a helper to extract a nested string from a map. func getNestedString(m map[string]interface{}, keys ...string) string { var current interface{} = m for _, key := range keys { if cm, ok := current.(map[string]interface{}); ok { current = cm[key] } else { return "" } } if s, ok := current.(string); ok { return s } return "" }