Files
ITWorx-Pulse-Public/internal/onboarding/service.go
T
ITWorx Pulse release export bd774932d5
Public source validation / validate (push) Failing after 3m8s
Publish ITWorx Pulse source
2026-09-03 02:09:19 +02:00

223 lines
8.0 KiB
Go

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
}