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,202 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/api"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
|
||||
"gitea.dooplex.hu/admin/felhom-hub/internal/web"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
Version = "dev"
|
||||
BuildTime = "unknown"
|
||||
)
|
||||
|
||||
// Config is the hub configuration loaded from hub.yaml.
|
||||
type Config struct {
|
||||
Auth struct {
|
||||
PasswordHash string `yaml:"password_hash"`
|
||||
} `yaml:"auth"`
|
||||
API struct {
|
||||
ReportAPIKey string `yaml:"report_api_key"`
|
||||
} `yaml:"api"`
|
||||
Retention struct {
|
||||
MaxDays int `yaml:"max_days"`
|
||||
PruneSchedule string `yaml:"prune_schedule"`
|
||||
} `yaml:"retention"`
|
||||
Alerting struct {
|
||||
StaleThreshold string `yaml:"stale_threshold"`
|
||||
} `yaml:"alerting"`
|
||||
Server struct {
|
||||
Listen string `yaml:"listen"`
|
||||
DataDir string `yaml:"data_dir"`
|
||||
} `yaml:"server"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "/etc/felhom-hub/hub.yaml", "Path to configuration file")
|
||||
showVersion := flag.Bool("version", false, "Show version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("felhom-hub %s (built %s)\n", Version, BuildTime)
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
logger := log.New(os.Stdout, "", log.LstdFlags)
|
||||
logger.Printf("[INFO] felhom-hub %s starting", Version)
|
||||
|
||||
// Load config
|
||||
cfg := loadConfig(*configPath, logger)
|
||||
|
||||
// Ensure data dir exists
|
||||
os.MkdirAll(cfg.Server.DataDir, 0755)
|
||||
|
||||
// Initialize store
|
||||
dbPath := filepath.Join(cfg.Server.DataDir, "hub.db")
|
||||
dataStore, err := store.New(dbPath, logger)
|
||||
if err != nil {
|
||||
logger.Fatalf("[FATAL] Failed to initialize store: %v", err)
|
||||
}
|
||||
defer dataStore.Close()
|
||||
logger.Printf("[INFO] Database opened at %s", dbPath)
|
||||
|
||||
// Parse stale threshold
|
||||
staleThreshold, err := time.ParseDuration(cfg.Alerting.StaleThreshold)
|
||||
if err != nil {
|
||||
staleThreshold = 30 * time.Minute
|
||||
}
|
||||
|
||||
// Initialize handlers
|
||||
apiHandler := api.New(dataStore, cfg.API.ReportAPIKey, logger)
|
||||
webServer := web.New(dataStore, cfg.Auth.PasswordHash, staleThreshold, logger)
|
||||
|
||||
// Build HTTP mux
|
||||
mux := http.NewServeMux()
|
||||
|
||||
// API routes (no dashboard auth for report ingest)
|
||||
mux.Handle("/api/v1/", apiHandler)
|
||||
|
||||
// Web routes (auth required)
|
||||
if cfg.Auth.PasswordHash != "" {
|
||||
mux.Handle("/", webServer.RequireAuth(http.HandlerFunc(webServer.ServeHTTP)))
|
||||
} else {
|
||||
mux.Handle("/", http.HandlerFunc(webServer.ServeHTTP))
|
||||
}
|
||||
|
||||
// Start HTTP server
|
||||
server := &http.Server{
|
||||
Addr: cfg.Server.Listen,
|
||||
Handler: mux,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 60 * time.Second,
|
||||
IdleTimeout: 120 * time.Second,
|
||||
}
|
||||
|
||||
// Background: daily prune
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
if cfg.Retention.MaxDays > 0 {
|
||||
go pruneLoop(ctx, dataStore, cfg.Retention.MaxDays, logger)
|
||||
}
|
||||
|
||||
// Signal handling
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
sig := <-sigCh
|
||||
logger.Printf("[INFO] Received signal %v, shutting down...", sig)
|
||||
cancel()
|
||||
|
||||
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer shutdownCancel()
|
||||
|
||||
if err := server.Shutdown(shutdownCtx); err != nil {
|
||||
logger.Printf("[ERROR] HTTP server shutdown error: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
logger.Printf("[INFO] Listening on %s", cfg.Server.Listen)
|
||||
if err := server.ListenAndServe(); err != http.ErrServerClosed {
|
||||
logger.Fatalf("[FATAL] HTTP server error: %v", err)
|
||||
}
|
||||
|
||||
logger.Println("[INFO] felhom-hub stopped")
|
||||
}
|
||||
|
||||
func loadConfig(path string, logger *log.Logger) *Config {
|
||||
cfg := &Config{}
|
||||
|
||||
// Defaults
|
||||
cfg.Server.Listen = ":8080"
|
||||
cfg.Server.DataDir = "/data"
|
||||
cfg.Retention.MaxDays = 90
|
||||
cfg.Retention.PruneSchedule = "04:30"
|
||||
cfg.Alerting.StaleThreshold = "30m"
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
logger.Printf("[WARN] Config file not found at %s, using defaults", path)
|
||||
return cfg
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
logger.Printf("[WARN] Failed to parse config: %v, using defaults", err)
|
||||
return cfg
|
||||
}
|
||||
|
||||
// Apply defaults for zero values
|
||||
if cfg.Server.Listen == "" {
|
||||
cfg.Server.Listen = ":8080"
|
||||
}
|
||||
if cfg.Server.DataDir == "" {
|
||||
cfg.Server.DataDir = "/data"
|
||||
}
|
||||
if cfg.Retention.MaxDays == 0 {
|
||||
cfg.Retention.MaxDays = 90
|
||||
}
|
||||
if cfg.Alerting.StaleThreshold == "" {
|
||||
cfg.Alerting.StaleThreshold = "30m"
|
||||
}
|
||||
|
||||
return cfg
|
||||
}
|
||||
|
||||
func pruneLoop(ctx context.Context, s *store.Store, maxDays int, logger *log.Logger) {
|
||||
// Prune once on startup
|
||||
if deleted, err := s.Prune(maxDays); err != nil {
|
||||
logger.Printf("[WARN] Prune failed: %v", err)
|
||||
} else if deleted > 0 {
|
||||
logger.Printf("[INFO] Pruned %d old report rows", deleted)
|
||||
}
|
||||
|
||||
// Then daily
|
||||
ticker := time.NewTicker(24 * time.Hour)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
if deleted, err := s.Prune(maxDays); err != nil {
|
||||
logger.Printf("[WARN] Prune failed: %v", err)
|
||||
} else if deleted > 0 {
|
||||
logger.Printf("[INFO] Pruned %d old report rows", deleted)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user