77b5a4ce4e
- 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>
270 lines
6.7 KiB
Go
270 lines
6.7 KiB
Go
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
|
||
}
|
||
}
|