Add felhom-hub: multi-customer dashboard service
- Hub service receives reports from customer controllers - SQLite store with 90-day retention and auto-prune - REST API: POST /api/v1/report, GET /api/v1/customers - Dark theme dashboard with status overview table - Customer detail page with system, storage, containers, backup, health - Bearer token auth for report ingest, bcrypt auth for dashboard - K8s manifest for felhom-system namespace (Deployment, Service, Ingress, PVC) - Dockerfile with multi-stage build Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
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) },
|
||||
"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(`<html><body><form method="post"><input type="password" name="password"><button>Login</button></form></body></html>`))
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
type detailData struct {
|
||||
Customer *store.CustomerSummary
|
||||
Report map[string]interface{}
|
||||
History []store.CustomerSummary
|
||||
OverallStatus string
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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 {
|
||||
switch status {
|
||||
case "ok":
|
||||
return "🟢" // green circle
|
||||
case "warn":
|
||||
return "🟡" // yellow circle
|
||||
case "down", "fail":
|
||||
return "🔴" // red circle
|
||||
default:
|
||||
return "⚪" // white circle
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user