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,184 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
)
|
||||
|
||||
// Handler handles API endpoints for report ingest and customer queries.
|
||||
type Handler struct {
|
||||
store *store.Store
|
||||
apiKey string
|
||||
logger *log.Logger
|
||||
}
|
||||
|
||||
// New creates a new API handler.
|
||||
func New(store *store.Store, apiKey string, logger *log.Logger) *Handler {
|
||||
return &Handler{
|
||||
store: store,
|
||||
apiKey: apiKey,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP routes API requests.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
path := strings.TrimPrefix(r.URL.Path, "/api/v1")
|
||||
|
||||
switch {
|
||||
case r.Method == http.MethodPost && path == "/report":
|
||||
h.handleReport(w, r)
|
||||
case r.Method == http.MethodGet && path == "/customers":
|
||||
h.handleCustomers(w, r)
|
||||
case r.Method == http.MethodGet && strings.HasPrefix(path, "/customers/"):
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/customers/"), "/")
|
||||
customerID := parts[0]
|
||||
if len(parts) > 1 && parts[1] == "history" {
|
||||
h.handleCustomerHistory(w, r, customerID)
|
||||
} else {
|
||||
h.handleCustomer(w, r, customerID)
|
||||
}
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) {
|
||||
// Verify bearer token
|
||||
if h.apiKey != "" {
|
||||
auth := r.Header.Get("Authorization")
|
||||
if !strings.HasPrefix(auth, "Bearer ") || strings.TrimPrefix(auth, "Bearer ") != h.apiKey {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
||||
if err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// Extract customer_id from JSON
|
||||
var payload struct {
|
||||
CustomerID string `json:"customer_id"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &payload); err != nil || payload.CustomerID == "" {
|
||||
http.Error(w, "Invalid payload: customer_id required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.store.SaveReport(payload.CustomerID, body); err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to save report from %s: %v", payload.CustomerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Printf("[INFO] Received report from %s (%d bytes)", payload.CustomerID, len(body))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
func (h *Handler) handleCustomers(w http.ResponseWriter, r *http.Request) {
|
||||
customers, err := h.store.GetCustomers()
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to get customers: %v", err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type customerJSON struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
ControllerVersion string `json:"controller_version"`
|
||||
HealthStatus string `json:"health_status"`
|
||||
LastSeen time.Time `json:"last_seen"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryPercent float64 `json:"memory_percent"`
|
||||
ContainerTotal int `json:"container_total"`
|
||||
ContainerRunning int `json:"container_running"`
|
||||
BackupLastSnapshot *time.Time `json:"backup_last_snapshot"`
|
||||
}
|
||||
|
||||
result := make([]customerJSON, 0, len(customers))
|
||||
for _, c := range customers {
|
||||
result = append(result, customerJSON{
|
||||
ID: c.CustomerID,
|
||||
Name: c.CustomerName,
|
||||
ControllerVersion: c.ControllerVersion,
|
||||
HealthStatus: c.HealthStatus,
|
||||
LastSeen: c.ReceivedAt,
|
||||
CPUPercent: c.CPUPercent,
|
||||
MemoryPercent: c.MemoryPercent,
|
||||
ContainerTotal: c.ContainerTotal,
|
||||
ContainerRunning: c.ContainerRunning,
|
||||
BackupLastSnapshot: c.BackupLastSnapshot,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
|
||||
func (h *Handler) handleCustomer(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
customer, err := h.store.GetCustomer(customerID)
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to get customer %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if customer == nil {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
// Return the full report JSON directly
|
||||
w.Write([]byte(customer.ReportJSON))
|
||||
}
|
||||
|
||||
func (h *Handler) handleCustomerHistory(w http.ResponseWriter, r *http.Request, customerID string) {
|
||||
period := r.URL.Query().Get("period")
|
||||
var since time.Duration
|
||||
switch period {
|
||||
case "7d":
|
||||
since = 7 * 24 * time.Hour
|
||||
case "30d":
|
||||
since = 30 * 24 * time.Hour
|
||||
default:
|
||||
since = 24 * time.Hour
|
||||
}
|
||||
|
||||
history, err := h.store.GetCustomerHistory(customerID, since)
|
||||
if err != nil {
|
||||
h.logger.Printf("[ERROR] Failed to get history for %s: %v", customerID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
type historyEntry struct {
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
HealthStatus string `json:"health_status"`
|
||||
CPUPercent float64 `json:"cpu_percent"`
|
||||
MemoryPercent float64 `json:"memory_percent"`
|
||||
}
|
||||
|
||||
result := make([]historyEntry, 0, len(history))
|
||||
for _, h := range history {
|
||||
result = append(result, historyEntry{
|
||||
ReceivedAt: h.ReceivedAt,
|
||||
HealthStatus: h.HealthStatus,
|
||||
CPUPercent: h.CPUPercent,
|
||||
MemoryPercent: h.MemoryPercent,
|
||||
})
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(result)
|
||||
}
|
||||
Reference in New Issue
Block a user