This commit is contained in:
@@ -0,0 +1,331 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "11111111-1111-4111-8111-111111111111",
|
||||
"slug": "overview",
|
||||
"name": "Overzicht",
|
||||
"description": "Standaard operationeel overzicht.",
|
||||
"scope": "system",
|
||||
"variables": [
|
||||
{
|
||||
"name": "server",
|
||||
"type": "server",
|
||||
"label": "Server",
|
||||
"default": "primary"
|
||||
},
|
||||
{
|
||||
"name": "timeRange",
|
||||
"type": "time-range",
|
||||
"label": "Periode",
|
||||
"default": "1h"
|
||||
}
|
||||
],
|
||||
"widgets": [
|
||||
{
|
||||
"id": "21111111-1111-4111-8111-111111111111",
|
||||
"type": "stat",
|
||||
"title": "Serverstatus",
|
||||
"description": "Samengevoegde status met verklaringen.",
|
||||
"data": {
|
||||
"sourceType": "inventory",
|
||||
"scope": {
|
||||
"entityType": "server",
|
||||
"variable": "server"
|
||||
},
|
||||
"transformations": []
|
||||
},
|
||||
"visualization": {
|
||||
"legend": false,
|
||||
"showSparkline": false,
|
||||
"decimals": 0,
|
||||
"thresholds": []
|
||||
},
|
||||
"behavior": {
|
||||
"locked": false,
|
||||
"hidden": false,
|
||||
"hideWhenEmpty": false,
|
||||
"showOnlyOnProblem": false,
|
||||
"link": "/infrastructure/host",
|
||||
"liveIntervalSeconds": 5,
|
||||
"independentTimeRange": null
|
||||
},
|
||||
"layouts": {
|
||||
"desktop": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4,
|
||||
"visible": true
|
||||
},
|
||||
"tablet": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 4,
|
||||
"visible": true
|
||||
},
|
||||
"mobile": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 1,
|
||||
"h": 4,
|
||||
"visible": true
|
||||
},
|
||||
"wallboard": {
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"w": 6,
|
||||
"h": 4,
|
||||
"visible": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "31111111-1111-4111-8111-111111111111",
|
||||
"type": "timeseries",
|
||||
"title": "CPU en belasting",
|
||||
"data": {
|
||||
"sourceType": "semantic-metric",
|
||||
"metric": "host.cpu.utilization",
|
||||
"scope": {
|
||||
"serverId": "$server"
|
||||
},
|
||||
"range": "$timeRange",
|
||||
"aggregation": "avg",
|
||||
"groupBy": [
|
||||
"instance"
|
||||
],
|
||||
"transformations": []
|
||||
},
|
||||
"visualization": {
|
||||
"unit": "percent",
|
||||
"decimals": 1,
|
||||
"legend": true,
|
||||
"showSparkline": false,
|
||||
"min": 0,
|
||||
"max": 100,
|
||||
"thresholds": []
|
||||
},
|
||||
"behavior": {
|
||||
"locked": false,
|
||||
"hidden": false,
|
||||
"hideWhenEmpty": false,
|
||||
"showOnlyOnProblem": false,
|
||||
"link": "/infrastructure/host",
|
||||
"liveIntervalSeconds": 2,
|
||||
"independentTimeRange": null
|
||||
},
|
||||
"layouts": {
|
||||
"desktop": {
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"w": 12,
|
||||
"h": 8,
|
||||
"visible": true
|
||||
},
|
||||
"tablet": {
|
||||
"x": 4,
|
||||
"y": 0,
|
||||
"w": 4,
|
||||
"h": 5,
|
||||
"visible": true
|
||||
},
|
||||
"mobile": {
|
||||
"x": 0,
|
||||
"y": 4,
|
||||
"w": 1,
|
||||
"h": 5,
|
||||
"visible": true
|
||||
},
|
||||
"wallboard": {
|
||||
"x": 6,
|
||||
"y": 0,
|
||||
"w": 10,
|
||||
"h": 6,
|
||||
"visible": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "41111111-1111-4111-8111-111111111111",
|
||||
"type": "storage-map",
|
||||
"title": "Array en pools",
|
||||
"data": {
|
||||
"sourceType": "inventory",
|
||||
"scope": {
|
||||
"entityTypes": [
|
||||
"array",
|
||||
"pool",
|
||||
"disk"
|
||||
]
|
||||
},
|
||||
"transformations": []
|
||||
},
|
||||
"visualization": {
|
||||
"legend": true,
|
||||
"showSparkline": false,
|
||||
"decimals": 1,
|
||||
"thresholds": []
|
||||
},
|
||||
"behavior": {
|
||||
"locked": false,
|
||||
"hidden": false,
|
||||
"hideWhenEmpty": false,
|
||||
"showOnlyOnProblem": false,
|
||||
"link": "/infrastructure/storage",
|
||||
"liveIntervalSeconds": 15,
|
||||
"independentTimeRange": null
|
||||
},
|
||||
"layouts": {
|
||||
"desktop": {
|
||||
"x": 0,
|
||||
"y": 8,
|
||||
"w": 9,
|
||||
"h": 8,
|
||||
"visible": true
|
||||
},
|
||||
"tablet": {
|
||||
"x": 0,
|
||||
"y": 5,
|
||||
"w": 8,
|
||||
"h": 7,
|
||||
"visible": true
|
||||
},
|
||||
"mobile": {
|
||||
"x": 0,
|
||||
"y": 9,
|
||||
"w": 1,
|
||||
"h": 7,
|
||||
"visible": true
|
||||
},
|
||||
"wallboard": {
|
||||
"x": 0,
|
||||
"y": 6,
|
||||
"w": 12,
|
||||
"h": 7,
|
||||
"visible": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "51111111-1111-4111-8111-111111111111",
|
||||
"type": "status-grid",
|
||||
"title": "Applicaties",
|
||||
"data": {
|
||||
"sourceType": "inventory",
|
||||
"scope": {
|
||||
"entityType": "application"
|
||||
},
|
||||
"transformations": [],
|
||||
"limit": 50
|
||||
},
|
||||
"visualization": {
|
||||
"legend": true,
|
||||
"showSparkline": false,
|
||||
"decimals": 0,
|
||||
"thresholds": []
|
||||
},
|
||||
"behavior": {
|
||||
"locked": false,
|
||||
"hidden": false,
|
||||
"hideWhenEmpty": false,
|
||||
"showOnlyOnProblem": false,
|
||||
"link": "/applications",
|
||||
"liveIntervalSeconds": 5,
|
||||
"independentTimeRange": null
|
||||
},
|
||||
"layouts": {
|
||||
"desktop": {
|
||||
"x": 9,
|
||||
"y": 8,
|
||||
"w": 9,
|
||||
"h": 8,
|
||||
"visible": true
|
||||
},
|
||||
"tablet": {
|
||||
"x": 0,
|
||||
"y": 12,
|
||||
"w": 8,
|
||||
"h": 7,
|
||||
"visible": true
|
||||
},
|
||||
"mobile": {
|
||||
"x": 0,
|
||||
"y": 16,
|
||||
"w": 1,
|
||||
"h": 7,
|
||||
"visible": true
|
||||
},
|
||||
"wallboard": {
|
||||
"x": 12,
|
||||
"y": 6,
|
||||
"w": 12,
|
||||
"h": 7,
|
||||
"visible": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "61111111-1111-4111-8111-111111111111",
|
||||
"type": "event-timeline",
|
||||
"title": "Recente gebeurtenissen",
|
||||
"data": {
|
||||
"sourceType": "events",
|
||||
"scope": {},
|
||||
"range": "$timeRange",
|
||||
"transformations": [],
|
||||
"limit": 100
|
||||
},
|
||||
"visualization": {
|
||||
"legend": false,
|
||||
"showSparkline": false,
|
||||
"decimals": 0,
|
||||
"thresholds": []
|
||||
},
|
||||
"behavior": {
|
||||
"locked": false,
|
||||
"hidden": false,
|
||||
"hideWhenEmpty": false,
|
||||
"showOnlyOnProblem": false,
|
||||
"link": "/events",
|
||||
"liveIntervalSeconds": 5,
|
||||
"independentTimeRange": null
|
||||
},
|
||||
"layouts": {
|
||||
"desktop": {
|
||||
"x": 0,
|
||||
"y": 16,
|
||||
"w": 18,
|
||||
"h": 7,
|
||||
"visible": true
|
||||
},
|
||||
"tablet": {
|
||||
"x": 0,
|
||||
"y": 19,
|
||||
"w": 8,
|
||||
"h": 7,
|
||||
"visible": true
|
||||
},
|
||||
"mobile": {
|
||||
"x": 0,
|
||||
"y": 23,
|
||||
"w": 1,
|
||||
"h": 7,
|
||||
"visible": true
|
||||
},
|
||||
"wallboard": {
|
||||
"x": 0,
|
||||
"y": 13,
|
||||
"w": 24,
|
||||
"h": 6,
|
||||
"visible": true
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"settings": {
|
||||
"defaultTimeRange": "1h",
|
||||
"live": true,
|
||||
"refreshSeconds": 5,
|
||||
"rotationSeconds": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/itworx/pulse/internal/alert"
|
||||
"github.com/itworx/pulse/internal/alertdefaults"
|
||||
"github.com/itworx/pulse/internal/dashboard"
|
||||
"github.com/itworx/pulse/internal/database"
|
||||
"github.com/itworx/pulse/internal/metriccatalog"
|
||||
)
|
||||
|
||||
func TestPostgreSQLOnboardingIsResumableAndIdempotent(t *testing.T) {
|
||||
dsn := os.Getenv("PULSE_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("PULSE_TEST_DATABASE_URL is not set")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := database.NewPool(ctx, database.Config{URL: dsn})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer pool.Close()
|
||||
if err := database.Migrate(ctx, pool); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
registry, err := metriccatalog.DefaultRegistry()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
alerts := alert.Repository{Pool: pool, Registry: registry}
|
||||
if _, err := alertdefaults.Seed(ctx, alerts, registry, "system-defaults"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
service := Service{State: StateStore{Pool: pool}, Pool: pool, Dashboards: dashboard.Repository{Pool: pool}, Alerts: alerts, AuthMode: "mock"}
|
||||
first, err := service.Complete(ctx, Choice{Dashboard: "default", Rules: "default"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := service.Complete(ctx, Choice{Dashboard: "default", Rules: "default"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !first.State.Completed || !second.State.Completed || first.State.DashboardID == "" || first.State.DashboardID != second.State.DashboardID {
|
||||
t.Fatalf("non-idempotent completion: first=%+v second=%+v", first.State, second.State)
|
||||
}
|
||||
var dashboards int
|
||||
if err := pool.QueryRow(ctx, `SELECT count(*) FROM dashboards WHERE slug=$1 AND archived_at IS NULL`, DefaultSlug).Scan(&dashboards); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dashboards != 1 {
|
||||
t.Fatalf("default dashboard count=%d, want 1", dashboards)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDefaultDashboardIsEmbeddedAndMigrated(t *testing.T) {
|
||||
document, err := defaultDashboard()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if document["id"] == nil || document["slug"] != DefaultSlug {
|
||||
t.Fatalf("unexpected default dashboard: %#v", document)
|
||||
}
|
||||
if document["schemaVersion"] != 2 {
|
||||
t.Fatalf("schema version=%v, want 2", document["schemaVersion"])
|
||||
}
|
||||
encoded, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(encoded), `"serverId":"$server"`) || strings.Contains(string(encoded), `"server":"$server"`) {
|
||||
t.Fatalf("default metric scope does not use the query planner alias: %s", encoded)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteRejectsUnknownChoicesBeforePersistence(t *testing.T) {
|
||||
service := Service{}
|
||||
if _, err := service.Complete(context.Background(), Choice{Dashboard: "unsafe", Rules: "default"}); err == nil {
|
||||
t.Fatal("expected invalid onboarding choice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidChoicesAreBounded(t *testing.T) {
|
||||
for _, value := range []string{"default", "skip"} {
|
||||
if !validChoice(value) {
|
||||
t.Fatalf("choice %q should be accepted", value)
|
||||
}
|
||||
}
|
||||
if validChoice("install-everything") {
|
||||
t.Fatal("unexpected unbounded onboarding choice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeCapabilitiesOverrideConfigurationPlaceholders(t *testing.T) {
|
||||
configured := []Capability{
|
||||
{ID: "database", State: "ready", Detail: "database"},
|
||||
{ID: "prometheus", State: "unknown", Detail: "not configured"},
|
||||
{ID: "unraid", State: "unknown", Detail: "not configured"},
|
||||
}
|
||||
observed := []Capability{
|
||||
{ID: "prometheus", State: "ready", Detail: "runtime sampled"},
|
||||
{ID: "unraid", State: "ready", Detail: "runtime sampled"},
|
||||
{ID: "unexpected", State: "ready", Detail: "must stay closed"},
|
||||
}
|
||||
merged := mergeCapabilities(configured, observed)
|
||||
if len(merged) != 3 {
|
||||
t.Fatalf("merged capabilities = %#v", merged)
|
||||
}
|
||||
for _, capability := range merged {
|
||||
if capability.ID == "prometheus" || capability.ID == "unraid" {
|
||||
if capability.State != "ready" || capability.Detail != "runtime sampled" {
|
||||
t.Fatalf("runtime capability not authoritative: %#v", capability)
|
||||
}
|
||||
}
|
||||
if capability.ID == "unexpected" {
|
||||
t.Fatal("runtime callback expanded the closed capability set")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package onboarding
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type StateStore struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func (s StateStore) Load(ctx context.Context) (State, error) {
|
||||
if s.Pool == nil {
|
||||
return State{}, errors.New("onboarding state store is not configured")
|
||||
}
|
||||
var raw []byte
|
||||
if err := s.Pool.QueryRow(ctx, `SELECT value FROM system_settings WHERE key=$1`, StateKey).Scan(&raw); err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return State{SchemaVersion: SchemaVersion, Step: "welcome"}, nil
|
||||
}
|
||||
return State{}, fmt.Errorf("load onboarding state: %w", err)
|
||||
}
|
||||
var state State
|
||||
if err := json.Unmarshal(raw, &state); err != nil {
|
||||
return State{}, fmt.Errorf("decode onboarding state: %w", err)
|
||||
}
|
||||
if state.SchemaVersion != SchemaVersion {
|
||||
return State{}, fmt.Errorf("unsupported onboarding state schema version %d", state.SchemaVersion)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (s StateStore) Save(ctx context.Context, state State) error {
|
||||
if s.Pool == nil {
|
||||
return errors.New("onboarding state store is not configured")
|
||||
}
|
||||
state.SchemaVersion = SchemaVersion
|
||||
state.UpdatedAt = time.Now().UTC()
|
||||
raw, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode onboarding state: %w", err)
|
||||
}
|
||||
if _, err := s.Pool.Exec(ctx, `INSERT INTO system_settings (key,value,version) VALUES ($1,$2::jsonb,1) ON CONFLICT (key) DO UPDATE SET value=EXCLUDED.value,version=system_settings.version+1,updated_at=now()`, StateKey, raw); err != nil {
|
||||
return fmt.Errorf("save onboarding state: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package onboarding
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
SchemaVersion = 1
|
||||
StateKey = "onboarding.v1"
|
||||
DefaultSlug = "overview"
|
||||
)
|
||||
|
||||
type State struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Completed bool `json:"completed"`
|
||||
Step string `json:"step"`
|
||||
DashboardChoice string `json:"dashboardChoice"`
|
||||
RulesChoice string `json:"rulesChoice"`
|
||||
DashboardID string `json:"dashboardId,omitempty"`
|
||||
RulesReady bool `json:"rulesReady"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
type Choice struct {
|
||||
Dashboard string `json:"dashboard"`
|
||||
Rules string `json:"rules"`
|
||||
}
|
||||
|
||||
type Capability struct {
|
||||
ID string `json:"id"`
|
||||
State string `json:"state"`
|
||||
Detail string `json:"detail"`
|
||||
}
|
||||
|
||||
type Status struct {
|
||||
State State `json:"state"`
|
||||
Capabilities []Capability `json:"capabilities"`
|
||||
Resume bool `json:"resume"`
|
||||
}
|
||||
Reference in New Issue
Block a user