This commit is contained in:
@@ -0,0 +1,222 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"embed"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/itworx/pulse/internal/alert"
|
||||
"github.com/itworx/pulse/internal/dashboard"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
//go:embed default-dashboard.json
|
||||
var defaultDashboardFS embed.FS
|
||||
|
||||
type Service struct {
|
||||
State StateStore
|
||||
Pool *pgxpool.Pool
|
||||
Dashboards dashboard.Repository
|
||||
Alerts alert.Store
|
||||
AuthMode string
|
||||
OIDCIssuer string
|
||||
OIDCClient string
|
||||
OIDCRedirect string
|
||||
Prometheus bool
|
||||
Unraid bool
|
||||
// RuntimeCapabilities supplies observed source readiness. It overrides the
|
||||
// configuration-only Prometheus and Unraid placeholders when present.
|
||||
RuntimeCapabilities func(context.Context) ([]Capability, error)
|
||||
}
|
||||
|
||||
func (s Service) Status(ctx context.Context) (Status, error) {
|
||||
state, err := s.State.Load(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
caps := make([]Capability, 0, 7)
|
||||
if s.Pool == nil {
|
||||
caps = append(caps, Capability{ID: "database", State: "unavailable", Detail: "De database is niet geconfigureerd."})
|
||||
} else if err := s.Pool.Ping(ctx); err != nil {
|
||||
caps = append(caps, Capability{ID: "database", State: "unknown", Detail: "De database is niet bereikbaar."})
|
||||
} else {
|
||||
caps = append(caps, Capability{ID: "database", State: "ready", Detail: "De database is beschikbaar."})
|
||||
}
|
||||
if s.Prometheus {
|
||||
caps = append(caps, Capability{ID: "prometheus", State: "configured", Detail: "Prometheus is geconfigureerd; de endpoint blijft verborgen."})
|
||||
} else {
|
||||
caps = append(caps, Capability{ID: "prometheus", State: "unknown", Detail: "Prometheus is nog niet geconfigureerd."})
|
||||
}
|
||||
if s.Unraid {
|
||||
caps = append(caps, Capability{ID: "unraid", State: "configured", Detail: "De read-only Unraid-bron is geconfigureerd."})
|
||||
} else {
|
||||
caps = append(caps, Capability{ID: "unraid", State: "unknown", Detail: "De read-only Unraid-bron is nog niet geconfigureerd."})
|
||||
}
|
||||
if s.Pool == nil {
|
||||
caps = append(caps, Capability{ID: "services", State: "unavailable", Detail: "Servicebewaking is niet beschikbaar zonder database."})
|
||||
} else {
|
||||
var enabled int
|
||||
if err := s.Pool.QueryRow(ctx, `SELECT count(*) FROM probes WHERE enabled=true AND archived_at IS NULL`).Scan(&enabled); err != nil {
|
||||
caps = append(caps, Capability{ID: "services", State: "unknown", Detail: "De actieve servicechecks konden niet worden gelezen."})
|
||||
} else if enabled == 0 {
|
||||
caps = append(caps, Capability{ID: "services", State: "not-ready", Detail: "Er zijn nog geen veilige servicechecks geconfigureerd."})
|
||||
} else {
|
||||
caps = append(caps, Capability{ID: "services", State: "ready", Detail: fmt.Sprintf("%d veilige read-only servicechecks zijn actief.", enabled)})
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case s.AuthMode == "mock":
|
||||
caps = append(caps, Capability{ID: "auth", State: "development", Detail: "Mock-authenticatie is actief voor development."})
|
||||
case strings.TrimSpace(s.OIDCIssuer) != "" && strings.TrimSpace(s.OIDCClient) != "" && strings.TrimSpace(s.OIDCRedirect) != "":
|
||||
caps = append(caps, Capability{ID: "auth", State: "ready", Detail: "OIDC-configuratie is compleet; secrets worden niet getoond."})
|
||||
default:
|
||||
caps = append(caps, Capability{ID: "auth", State: "incomplete", Detail: "OIDC-configuratie is nog niet compleet."})
|
||||
}
|
||||
if s.Alerts == nil {
|
||||
caps = append(caps, Capability{ID: "default-rules", State: "unavailable", Detail: "De alertregels zijn niet beschikbaar."})
|
||||
} else if rules, listErr := s.Alerts.List(ctx, 100); listErr != nil {
|
||||
caps = append(caps, Capability{ID: "default-rules", State: "unknown", Detail: "De alertregels konden niet worden gelezen."})
|
||||
} else if len(rules) == 0 {
|
||||
caps = append(caps, Capability{ID: "default-rules", State: "not-ready", Detail: "Er zijn nog geen alertregels beschikbaar."})
|
||||
} else {
|
||||
caps = append(caps, Capability{ID: "default-rules", State: "ready", Detail: fmt.Sprintf("%d alertregels zijn beschikbaar.", len(rules))})
|
||||
}
|
||||
if state.DashboardID != "" {
|
||||
caps = append(caps, Capability{ID: "default-dashboard", State: "ready", Detail: "Het standaarddashboard is geselecteerd."})
|
||||
} else {
|
||||
caps = append(caps, Capability{ID: "default-dashboard", State: "not-ready", Detail: "Het standaarddashboard is nog niet geselecteerd."})
|
||||
}
|
||||
if s.RuntimeCapabilities != nil {
|
||||
observed, observedErr := s.RuntimeCapabilities(ctx)
|
||||
if observedErr != nil {
|
||||
return Status{}, fmt.Errorf("read runtime capabilities: %w", observedErr)
|
||||
}
|
||||
caps = mergeCapabilities(caps, observed)
|
||||
}
|
||||
sort.Slice(caps, func(i, j int) bool { return caps[i].ID < caps[j].ID })
|
||||
return Status{State: state, Capabilities: caps, Resume: !state.Completed && state.Step != "" && state.Step != "welcome"}, nil
|
||||
}
|
||||
|
||||
func mergeCapabilities(configured, observed []Capability) []Capability {
|
||||
byID := make(map[string]Capability, len(configured)+len(observed))
|
||||
for _, capability := range configured {
|
||||
byID[capability.ID] = capability
|
||||
}
|
||||
for _, capability := range observed {
|
||||
if capability.ID == "prometheus" || capability.ID == "unraid" {
|
||||
byID[capability.ID] = capability
|
||||
}
|
||||
}
|
||||
result := make([]Capability, 0, len(byID))
|
||||
for _, capability := range byID {
|
||||
result = append(result, capability)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (s Service) Complete(ctx context.Context, choice Choice) (Status, error) {
|
||||
if choice.Dashboard == "" {
|
||||
choice.Dashboard = "default"
|
||||
}
|
||||
if choice.Rules == "" {
|
||||
choice.Rules = "default"
|
||||
}
|
||||
if !validChoice(choice.Dashboard) || !validChoice(choice.Rules) {
|
||||
return Status{}, errors.New("onboarding choices must be default or skip")
|
||||
}
|
||||
state, err := s.State.Load(ctx)
|
||||
if err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
state.DashboardChoice = choice.Dashboard
|
||||
state.RulesChoice = choice.Rules
|
||||
state.Step = "dashboard"
|
||||
if err := s.State.Save(ctx, state); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
if choice.Dashboard == "default" {
|
||||
if state.DashboardID == "" {
|
||||
id, ensureErr := s.ensureDefaultDashboard(ctx)
|
||||
if ensureErr != nil {
|
||||
return Status{}, ensureErr
|
||||
}
|
||||
state.DashboardID = id
|
||||
}
|
||||
}
|
||||
state.Step = "rules"
|
||||
if err := s.State.Save(ctx, state); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
if s.Alerts != nil {
|
||||
rules, listErr := s.Alerts.List(ctx, 100)
|
||||
if listErr != nil {
|
||||
return Status{}, fmt.Errorf("read default alert rules: %w", listErr)
|
||||
}
|
||||
state.RulesReady = len(rules) > 0
|
||||
}
|
||||
if choice.Rules == "default" && !state.RulesReady {
|
||||
return Status{}, errors.New("default alert rules are not ready")
|
||||
}
|
||||
state.Completed = true
|
||||
state.Step = "complete"
|
||||
if err := s.State.Save(ctx, state); err != nil {
|
||||
return Status{}, err
|
||||
}
|
||||
return s.Status(ctx)
|
||||
}
|
||||
|
||||
func validChoice(value string) bool { return value == "default" || value == "skip" }
|
||||
|
||||
func (s Service) ensureDefaultDashboard(ctx context.Context) (string, error) {
|
||||
if s.Dashboards.Pool == nil {
|
||||
return "", errors.New("dashboard repository is not configured")
|
||||
}
|
||||
items, err := s.Dashboards.List(ctx, "onboarding", 100)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("find default dashboard: %w", err)
|
||||
}
|
||||
for _, item := range items {
|
||||
if item.Slug == DefaultSlug && item.ArchivedAt == nil {
|
||||
return item.ID, nil
|
||||
}
|
||||
}
|
||||
document, err := defaultDashboard()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
created, _, err := s.Dashboards.Create(ctx, "system-defaults", document, "first-run default dashboard")
|
||||
if err != nil {
|
||||
if errors.Is(err, dashboard.ErrConflict) {
|
||||
items, listErr := s.Dashboards.List(ctx, "onboarding", 100)
|
||||
if listErr == nil {
|
||||
for _, item := range items {
|
||||
if item.Slug == DefaultSlug && item.ArchivedAt == nil {
|
||||
return item.ID, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "", fmt.Errorf("install default dashboard: %w", err)
|
||||
}
|
||||
return created.ID, nil
|
||||
}
|
||||
|
||||
func defaultDashboard() (dashboard.Document, error) {
|
||||
raw, err := defaultDashboardFS.ReadFile("default-dashboard.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read embedded default dashboard: %w", err)
|
||||
}
|
||||
var document dashboard.Document
|
||||
if err := json.Unmarshal(raw, &document); err != nil {
|
||||
return nil, fmt.Errorf("decode embedded default dashboard: %w", err)
|
||||
}
|
||||
migrated, err := dashboard.Migrate(document)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("validate embedded default dashboard: %w", err)
|
||||
}
|
||||
return migrated, nil
|
||||
}
|
||||
Reference in New Issue
Block a user