package web import ( "encoding/json" "fmt" "html/template" "log" "math" "net/http" "strings" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/store" "golang.org/x/crypto/bcrypt" ) // Server handles the dashboard web UI. type Server struct { store *store.Store passwordHash string logger *log.Logger templates *template.Template staleThreshold time.Duration } // New creates a new web server. func New(store *store.Store, passwordHash string, staleThreshold time.Duration, logger *log.Logger) *Server { funcMap := template.FuncMap{ "timeAgo": timeAgo, "statusColor": statusColor, "statusIcon": statusIcon, "formatFloat": func(f float64) string { return fmt.Sprintf("%.0f", f) }, "joinStrings": func(s []string, sep string) string { return strings.Join(s, sep) }, "json": func(v interface{}) template.JS { b, _ := json.Marshal(v) return template.JS(b) }, } tmpl := template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "templates/*.html")) return &Server{ store: store, passwordHash: passwordHash, logger: logger, templates: tmpl, staleThreshold: staleThreshold, } } // ServeHTTP routes web requests. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { path := r.URL.Path switch { case path == "/": s.handleDashboard(w, r) case path == "/style.css": s.handleCSS(w, r) case path == "/login": s.handleLogin(w, r) case strings.HasPrefix(path, "/customers/"): customerID := strings.TrimPrefix(path, "/customers/") s.handleCustomerDetail(w, r, customerID) default: http.NotFound(w, r) } } // RequireAuth wraps a handler with basic authentication. func (s *Server) RequireAuth(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Skip auth if no password configured if s.passwordHash == "" { next.ServeHTTP(w, r) return } // Check session cookie if cookie, err := r.Cookie("hub_session"); err == nil && cookie.Value == "authenticated" { next.ServeHTTP(w, r) return } // Check basic auth _, password, ok := r.BasicAuth() if ok && bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil { next.ServeHTTP(w, r) return } // Show login page for browser requests if r.URL.Path == "/login" && r.Method == http.MethodPost { s.handleLogin(w, r) return } w.Header().Set("WWW-Authenticate", `Basic realm="Felhom Hub"`) http.Error(w, "Unauthorized", http.StatusUnauthorized) }) } func (s *Server) handleLogin(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodPost { password := r.FormValue("password") if bcrypt.CompareHashAndPassword([]byte(s.passwordHash), []byte(password)) == nil { http.SetCookie(w, &http.Cookie{ Name: "hub_session", Value: "authenticated", Path: "/", HttpOnly: true, MaxAge: 86400 * 7, // 7 days }) http.Redirect(w, r, "/", http.StatusSeeOther) return } http.Error(w, "Invalid password", http.StatusUnauthorized) return } w.WriteHeader(http.StatusOK) w.Write([]byte(`
`)) } func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) { customers, err := s.store.GetCustomers() if err != nil { s.logger.Printf("[ERROR] Dashboard: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } type dashboardCustomer struct { store.CustomerSummary OverallStatus string // "ok", "warn", "down" BackupAge string } var data []dashboardCustomer for _, c := range customers { dc := dashboardCustomer{CustomerSummary: c} // Determine overall status if c.TimeSinceReport > time.Hour { dc.OverallStatus = "down" } else if c.TimeSinceReport > 30*time.Minute || c.HealthStatus == "warn" { dc.OverallStatus = "warn" } else if c.HealthStatus == "fail" { dc.OverallStatus = "down" } else { dc.OverallStatus = "ok" } // Backup age if c.BackupLastSnapshot != nil { dc.BackupAge = timeAgo(*c.BackupLastSnapshot) } else { dc.BackupAge = "–" } data = append(data, dc) } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.templates.ExecuteTemplate(w, "dashboard.html", data); err != nil { s.logger.Printf("[ERROR] Template render: %v", err) } } func (s *Server) handleCustomerDetail(w http.ResponseWriter, r *http.Request, customerID string) { customer, err := s.store.GetCustomer(customerID) if err != nil { s.logger.Printf("[ERROR] Customer detail: %v", err) http.Error(w, "Internal error", http.StatusInternalServerError) return } if customer == nil { http.NotFound(w, r) return } // Parse the full report var report map[string]interface{} json.Unmarshal([]byte(customer.ReportJSON), &report) // Get history (last 24h) history, _ := s.store.GetCustomerHistory(customerID, 24*time.Hour) // Get notification preferences and recent log notifPrefs, _ := s.store.GetNotificationPrefs(customerID) recentNotifs, _ := s.store.GetRecentNotifications(customerID, 10) type detailData struct { Customer *store.CustomerSummary Report map[string]interface{} History []store.CustomerSummary OverallStatus string NotifPrefs *store.NotificationPrefs RecentNotifications []store.NotificationLogEntry } overallStatus := "ok" if customer.TimeSinceReport > time.Hour { overallStatus = "down" } else if customer.TimeSinceReport > 30*time.Minute || customer.HealthStatus == "warn" { overallStatus = "warn" } else if customer.HealthStatus == "fail" { overallStatus = "down" } data := detailData{ Customer: customer, Report: report, History: history, OverallStatus: overallStatus, NotifPrefs: notifPrefs, RecentNotifications: recentNotifs, } w.Header().Set("Content-Type", "text/html; charset=utf-8") if err := s.templates.ExecuteTemplate(w, "customer.html", data); err != nil { s.logger.Printf("[ERROR] Template render: %v", err) } } func (s *Server) handleCSS(w http.ResponseWriter, r *http.Request) { data, err := templateFS.ReadFile("templates/style.css") if err != nil { http.NotFound(w, r) return } w.Header().Set("Content-Type", "text/css") w.Header().Set("Cache-Control", "public, max-age=3600") w.Write(data) } func timeAgo(t time.Time) string { d := time.Since(t) if d < time.Minute { return "just now" } if d < time.Hour { m := int(math.Round(d.Minutes())) return fmt.Sprintf("%d min ago", m) } if d < 24*time.Hour { h := int(math.Round(d.Hours())) return fmt.Sprintf("%dh ago", h) } days := int(d.Hours() / 24) return fmt.Sprintf("%dd ago", days) } func statusColor(status string) string { switch status { case "ok": return "#4ade80" // green case "warn": return "#facc15" // yellow case "down", "fail": return "#f87171" // red default: return "#94a3b8" // gray } } func statusIcon(status string) string { return "●" }