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 }