diff --git a/go.mod b/go.mod index 487c61b..f8ebf4c 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.25.0 require ( github.com/containeroo/resolver v0.3.1 - github.com/containeroo/tinyflags v0.0.57 + github.com/containeroo/tinyflags v0.0.61 github.com/prometheus/client_golang v1.23.2 github.com/stretchr/testify v1.11.1 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index f79ebbd..8fbf57b 100644 --- a/go.sum +++ b/go.sum @@ -4,8 +4,8 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/containeroo/resolver v0.3.1 h1:a9WY7ArZJxndQDFkcqWlH+AKB1lL//QeNYa5lJvpvv0= github.com/containeroo/resolver v0.3.1/go.mod h1:98QSZmbWP7A2YR2EU2SJZe34iFPKh2SDI9zJr2vO+Z8= -github.com/containeroo/tinyflags v0.0.57 h1:ckQxJpeY9T8OSYHOscB2KIc4w4kXKHh1BUm1iEsoIrA= -github.com/containeroo/tinyflags v0.0.57/go.mod h1:SxHHkI4dTMtsXwhKuw9Q2pCqxoX/ULRwUDteqsfFczM= +github.com/containeroo/tinyflags v0.0.61 h1:mgttLmuE2NKZMvkRyV6jl9m63AJdwkWyL6AwUJqZfWY= +github.com/containeroo/tinyflags v0.0.61/go.mod h1:SxHHkI4dTMtsXwhKuw9Q2pCqxoX/ULRwUDteqsfFczM= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/internal/app/run.go b/internal/app/run.go index c3be2e7..093532c 100644 --- a/internal/app/run.go +++ b/internal/app/run.go @@ -48,10 +48,12 @@ func Run(ctx context.Context, webFS fs.FS, version, commit string, args []string // Setup logger logger := logging.SetupLogger(flags.LogFormat, flags.Debug, w) - logger.Info("Starting Heartbeats", - "version", version, - "commit", commit, - ) + logger.Info("Starting Heartbeats", "version", version, "commit", commit) + + // Log CLI overrides + if len(flags.OverriddenValues) > 0 { + logger.Info("CLI Overrides", "overrides", flags.OverriddenValues) + } // Create history cache history, err := history.InitializeHistory(flags) diff --git a/internal/flag/flag.go b/internal/flag/flag.go index 0c4eed6..c04b899 100644 --- a/internal/flag/flag.go +++ b/internal/flag/flag.go @@ -14,17 +14,18 @@ import ( // Options holds the application configuration. type Options struct { - Debug bool // Set LogLevel to Debug - DebugServerPort int // Port for the debug server - LogFormat logging.LogFormat // Specify the log output format - ConfigPath string // Path to the configuration file - ListenAddr string // Address to listen on - SiteRoot string // Root URL of the site - RoutePrefix string // Route prefix - HistorySize int // Size of the history ring buffer - SkipTLS bool // Skip TLS for all receivers - RetryCount int // Number of retries for notifications - RetryDelay time.Duration // Delay between retries + Debug bool // Set LogLevel to Debug + DebugServerPort int // Port for the debug server + LogFormat logging.LogFormat // Specify the log output format + ConfigPath string // Path to the configuration file + ListenAddr string // Address to listen on + SiteRoot string // Root URL of the site + RoutePrefix string // Route prefix + HistorySize int // Size of the history ring buffer + SkipTLS bool // Skip TLS for all receivers + RetryCount int // Number of retries for notifications + RetryDelay time.Duration // Delay between retries + OverriddenValues map[string]any // Overridden values from environment } // ParseFlags parses flags and environment variables. @@ -51,6 +52,9 @@ func ParseFlags(args []string, version string) (Options, error) { Value() tf.StringVar(&opts.SiteRoot, "site-root", "http://localhost:8080", "Site root URL"). + Finalize(func(input string) string { + return strings.TrimRight(input, "/") + }). Short("r"). Placeholder("URL"). Value() @@ -108,10 +112,9 @@ func ParseFlags(args []string, version string) (Options, error) { opts.ListenAddr = (*listenAddr).String() opts.LogFormat = logging.LogFormat(*logFormat) - - // normalize and join SiteRoot + RoutePrefix - if opts.RoutePrefix != "" { - opts.SiteRoot = strings.TrimRight(opts.SiteRoot, "/") + opts.RoutePrefix + opts.OverriddenValues = tf.OverriddenValues() + if opts.RoutePrefix != "" && !strings.HasSuffix(opts.SiteRoot, opts.RoutePrefix) { + opts.SiteRoot += opts.RoutePrefix } return opts, nil diff --git a/internal/handlers/bump.go b/internal/handlers/bump.go index cc1fb04..46baa32 100644 --- a/internal/handlers/bump.go +++ b/internal/handlers/bump.go @@ -1,12 +1,14 @@ package handlers import ( + "errors" "fmt" "log/slog" "net/http" "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/service/bump" ) // BumpHandler handles POST/GET /heartbeat/{id}. @@ -18,32 +20,15 @@ func BumpHandler(mgr *heartbeat.Manager, hist history.Store, logger *slog.Logger return } - src := r.RemoteAddr - - if mgr.Get(id) == nil { - logger.Warn("received bump for unknown heartbeat ID", "id", id, "from", src) - http.Error(w, fmt.Sprintf("unknown heartbeat id %q", id), http.StatusNotFound) - return - } - - logger.Info("received bump", "id", id, "from", src) - - payload := history.RequestMetadataPayload{ - Source: src, - Method: r.Method, - UserAgent: r.UserAgent(), - } - ev := history.MustNewEvent(history.EventTypeHeartbeatReceived, id, payload) - - if err := hist.Append(r.Context(), ev); err != nil { - logger.Error("failed to record state change", "err", err) + if err := bump.Receive(r.Context(), mgr, hist, logger, id, r.RemoteAddr, r.Method, r.UserAgent()); err != nil { + if errors.Is(err, bump.ErrUnknownHeartbeat) { + http.Error(w, fmt.Sprintf("unknown heartbeat id %q", id), http.StatusNotFound) + return + } http.Error(w, err.Error(), http.StatusInternalServerError) return } - // We check if the heartbeat exists before calling HandleReceive - mgr.Receive(id) // nolint:errcheck - w.WriteHeader(http.StatusOK) fmt.Fprint(w, "ok") // nolint:errcheck }) @@ -58,32 +43,15 @@ func FailHandler(mgr *heartbeat.Manager, hist history.Store, logger *slog.Logger return } - src := r.RemoteAddr - - if mgr.Get(id) == nil { - logger.Warn("received /fail bump for unknown heartbeat ID", "id", id, "from", src) - http.Error(w, fmt.Sprintf("unknown heartbeat id %q", id), http.StatusNotFound) - return - } - - logger.Info("manual fail", "id", id, "from", src) - - payload := history.RequestMetadataPayload{ - Source: src, - Method: r.Method, - UserAgent: r.UserAgent(), - } - ev := history.MustNewEvent(history.EventTypeHeartbeatFailed, id, payload) - - if err := hist.Append(r.Context(), ev); err != nil { - logger.Error("failed to record state change", "err", err) + if err := bump.Fail(r.Context(), mgr, hist, logger, id, r.RemoteAddr, r.Method, r.UserAgent()); err != nil { + if errors.Is(err, bump.ErrUnknownHeartbeat) { + http.Error(w, fmt.Sprintf("unknown heartbeat id %q", id), http.StatusNotFound) + return + } http.Error(w, err.Error(), http.StatusInternalServerError) return } - // We check if the heartbeat exists before calling HandleFail - mgr.Fail(id) // nolint:errcheck - w.WriteHeader(http.StatusOK) fmt.Fprint(w, "ok") // nolint:errcheck }) diff --git a/internal/handlers/partials.go b/internal/handlers/partials.go index 65c108e..750fef4 100644 --- a/internal/handlers/partials.go +++ b/internal/handlers/partials.go @@ -1,54 +1,19 @@ package handlers import ( - "fmt" - "io" "io/fs" "log/slog" "net/http" "path" - "sort" "strings" "text/template" - "time" "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/history" "github.com/containeroo/heartbeats/internal/notifier" + "github.com/containeroo/heartbeats/internal/view" ) -type HeartbeatView struct { - ID string - Status string - Description string - Interval string - IntervalSeconds float64 // for table sorting - Grace string - GraceSeconds float64 // for table sorting - LastBump time.Time - URL string // full URL to copy - Receivers []string - HasHistory bool -} - -// ReceiverView for rendering each notifier instance. -type ReceiverView struct { - ID string // receiver ID - Type string // "slack", "email", etc. - Destination string // e.g. channel name, email addrs - LastSent time.Time - LastErr error -} - -// HistoryView is what the template actually sees. -// Details is already formatted for display. -type HistoryView struct { - Timestamp time.Time - Type string // EventType - HeartbeatID string - Details string // e.g. "Notification Sent", "old → new", or blank -} - // PartialHandler serves HTML snippets for dashboard sections: heartbeats, receivers, history. func PartialHandler( webFS fs.FS, @@ -76,11 +41,11 @@ func PartialHandler( switch section { case "heartbeats": - err = renderHeartbeats(w, tmpl, bumpURL, mgr, hist) + err = view.RenderHeartbeats(w, tmpl, bumpURL, mgr, hist) case "receivers": - err = renderReceivers(w, tmpl, disp) + err = view.RenderReceivers(w, tmpl, disp) case "history": - err = renderHistory(w, tmpl, hist) + err = view.RenderHistory(w, tmpl, hist) default: http.NotFound(w, r) return @@ -92,136 +57,3 @@ func PartialHandler( } } } - -// renderHeartbeats builds HeartbeatView slice, sorts it, and executes the template. -func renderHeartbeats( - w io.Writer, - tmpl *template.Template, - bumpURL string, - mgr *heartbeat.Manager, - hist history.Store, -) error { - actors := mgr.List() - views := make([]HeartbeatView, 0, len(actors)) - for id, a := range actors { - evs := hist.ListByID(id) - - views = append(views, HeartbeatView{ - ID: id, - Status: a.State.String(), - Description: a.Description, - Interval: a.Interval.String(), - IntervalSeconds: a.Interval.Seconds(), - Grace: a.Grace.String(), - LastBump: a.LastBump, - URL: bumpURL + id, - Receivers: a.Receivers, - HasHistory: len(evs) > 0, - }) - } - // Sort alphabetically by ID for consistent ordering - sort.Slice(views, func(i, j int) bool { return views[i].ID < views[j].ID }) - - data := struct { - Heartbeats []HeartbeatView - }{ - Heartbeats: views, - } - return tmpl.ExecuteTemplate(w, "heartbeats", data) -} - -// renderReceivers builds ReceiverView slice, sorts it by (ID,Type), and executes the template. -func renderReceivers( - w io.Writer, - tmpl *template.Template, - disp *notifier.Dispatcher, -) error { - raw := disp.List() - - views := make([]ReceiverView, 0, len(raw)) - for rid, nots := range raw { - for _, n := range nots { - rv := ReceiverView{ - ID: rid, - Type: n.Type(), - Destination: n.Target(), - LastSent: n.LastSent(), - LastErr: n.LastErr(), - } - - views = append(views, rv) - } - } - - // Sort alphabetically by ID for consistent ordering - sort.Slice(views, func(i, j int) bool { return views[i].ID < views[j].ID }) - - data := struct{ Receivers []ReceiverView }{Receivers: views} - - return tmpl.ExecuteTemplate(w, "receivers", data) -} - -// renderHistory sorts events newest-first, builds the filter list, and executes the template. -func renderHistory( - w io.Writer, - tmpl *template.Template, - hist history.Store, -) error { - raw := hist.List() - - views := make([]HistoryView, 0, len(raw)) - for _, e := range raw { - var det string - - switch e.Type { - case history.EventTypeNotificationSent, history.EventTypeNotificationFailed: - var p history.NotificationPayload - if err := e.DecodePayload(&p); err == nil { - if p.Error != "" { - det = fmt.Sprintf("Notification to %q via %s (%s) failed: %s", - p.Receiver, p.Type, p.Target, p.Error, - ) - } else { - det = fmt.Sprintf("Notification sent to %q via %s (%s)", - p.Receiver, p.Type, p.Target, - ) - } - } else { - det = "Invalid notification payload" - } - - case history.EventTypeStateChanged: - var p history.StateChangePayload - if err := e.DecodePayload(&p); err == nil { - det = fmt.Sprintf("%s → %s", p.From, p.To) - } else { - det = "Invalid state change payload" - } - - case history.EventTypeHeartbeatReceived, history.EventTypeHeartbeatFailed: - var p history.RequestMetadataPayload - if err := e.DecodePayload(&p); err == nil { - det = fmt.Sprintf("%s from %s with %q", p.Method, p.Source, p.UserAgent) - } else { - det = "Invalid request metadata" - } - - default: - det = "Unknown event type" - } - - views = append(views, HistoryView{ - Timestamp: e.Timestamp, - Type: e.Type.String(), - HeartbeatID: e.HeartbeatID, - Details: det, - }) - } - - // Newest first - sort.Slice(views, func(i, j int) bool { - return views[j].Timestamp.Before(views[i].Timestamp) - }) - - return tmpl.ExecuteTemplate(w, "history", struct{ Events []HistoryView }{Events: views}) -} diff --git a/internal/handlers/partials_test.go b/internal/handlers/partials_test.go index 0acebc4..6bd6af4 100644 --- a/internal/handlers/partials_test.go +++ b/internal/handlers/partials_test.go @@ -1,16 +1,13 @@ package handlers import ( - "bytes" "context" - "encoding/json" "log/slog" "net/http" "net/http/httptest" "strings" "testing" "testing/fstest" - "text/template" "time" "github.com/containeroo/heartbeats/internal/heartbeat" @@ -19,30 +16,6 @@ import ( "github.com/stretchr/testify/assert" ) -func loadTestTemplate(t *testing.T, name string, content string) *template.Template { - t.Helper() - - fs := fstest.MapFS{ - "web/templates/" + name + ".html": &fstest.MapFile{Data: []byte(content)}, - } - tmpl, err := template.New(name). - Funcs(notifier.FuncMap()). - ParseFS(fs, "web/templates/"+name+".html") - - assert.NoError(t, err) - - return tmpl -} - -func encodePayload(t *testing.T, payload any) json.RawMessage { - t.Helper() - data, err := json.Marshal(payload) - if err != nil { - t.Fatalf("failed to marshal payload: %v", err) - } - return data -} - func TestPartialHandler(t *testing.T) { t.Parallel() @@ -127,191 +100,3 @@ func TestPartialHandler(t *testing.T) { assert.Contains(t, rr.Body.String(), "HISTORY") }) } - -func TestRenderHeartbeats(t *testing.T) { - t.Parallel() - - tmpl := loadTestTemplate(t, "heartbeats", `{{define "heartbeats"}}{{range .Heartbeats}}{{.ID}}:{{.Status}};{{end}}{{end}}`) - - hist := history.NewRingStore(10) - logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) - store := notifier.InitializeStore(nil, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, nil, hist, 1, 1, 10) - mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), map[string]heartbeat.HeartbeatConfig{ - "b": { - Description: "b-desc", - Interval: 1 * time.Second, - Grace: 1 * time.Second, - Receivers: []string{"r1"}, - }, - "a": { - Description: "a-desc", - Interval: 1 * time.Second, - Grace: 1 * time.Second, - Receivers: []string{"r1"}, - }, - }, disp.Mailbox(), hist, nil) - - var buf bytes.Buffer - a := mgr.Get("b") - a.LastBump = time.Now() - - err := renderHeartbeats(&buf, tmpl, "http://localhost", mgr, hist) - assert.NoError(t, err) - assert.Contains(t, buf.String(), "a:idle;b:idle") -} - -func TestRenderReceivers(t *testing.T) { - t.Parallel() - - tmpl := loadTestTemplate(t, "receivers", `{{define "receivers"}}{{range .Receivers}}{{.Type}};{{end}}{{end}}`) - - r := notifier.ReceiverConfig{ - SlackConfigs: []notifier.SlackConfig{{Channel: "channel"}}, - MSTeamsConfigs: []notifier.MSTeamsConfig{{WebhookURL: "example.com"}}, - EmailConfigs: []notifier.EmailConfig{{EmailDetails: notifier.EmailDetails{To: []string{"to"}}}}, - } - rc := map[string]notifier.ReceiverConfig{"r": r} - - logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) - store := notifier.InitializeStore(rc, false, "0.0.0", logger) - disp := notifier.NewDispatcher(store, nil, nil, 1, 1, 10) - - var buf bytes.Buffer - err := renderReceivers(&buf, tmpl, disp) - assert.NoError(t, err) - assert.Equal(t, buf.String(), "slack;email;msteams;") -} - -func TestRenderHistory(t *testing.T) { - t.Parallel() - - tmpl := loadTestTemplate(t, "history", `{{define "history"}}{{range .Events}}{{.Type}}:{{.Details}};{{end}}{{end}}`) - - hist := history.NewRingStore(10) - - // Record state change event - _ = hist.Append(context.Background(), history.MustNewEvent( - history.EventTypeStateChanged, - "hb1", - history.StateChangePayload{ - From: "missing", - To: "received", - }, - )) - - // Record notification event - _ = hist.Append(context.Background(), history.MustNewEvent( - history.EventTypeNotificationSent, - "hb1", - history.NotificationPayload{ - Receiver: "r1", - Type: "mock-type", - Target: "mock-target", - }, - )) - - var buf bytes.Buffer - err := renderHistory(&buf, tmpl, hist) - - assert.NoError(t, err) - assert.Equal(t, - "NotificationSent:Notification sent to \"r1\" via mock-type (mock-target);StateChanged:missing → received;", - buf.String(), - ) - - t.Run("notification failed", func(t *testing.T) { - t.Parallel() - - hist := history.NewRingStore(10) - _ = hist.Append(context.Background(), history.MustNewEvent( - history.EventTypeNotificationFailed, - "hb2", - history.NotificationPayload{ - Receiver: "r2", - Type: "mock-type", - Target: "mock-target", - Error: "fail!", - }, - )) - - var buf bytes.Buffer - err := renderHistory(&buf, tmpl, hist) - assert.NoError(t, err) - assert.Contains(t, buf.String(), `Notification to "r2" via mock-type (mock-target) failed: fail!`) - }) - - t.Run("invalid notification payload", func(t *testing.T) { - t.Parallel() - - hist := history.NewRingStore(10) - ev := history.Event{ - Timestamp: time.Now(), - Type: history.EventTypeNotificationSent, - HeartbeatID: "hb3", - RawPayload: json.RawMessage(`{invalid-json}`), // deliberately broken - } - _ = hist.Append(context.Background(), ev) - - var buf bytes.Buffer - err := renderHistory(&buf, tmpl, hist) - assert.NoError(t, err) - assert.Contains(t, buf.String(), "Invalid notification payload") - }) - - t.Run("invalid state change", func(t *testing.T) { - t.Parallel() - - hist := history.NewRingStore(10) - - // Force an invalid payload: a raw string is not a struct - ev := history.Event{ - Timestamp: time.Now(), - Type: history.EventTypeStateChanged, - HeartbeatID: "hb4", - RawPayload: json.RawMessage(`"not a valid state change"`), - } - _ = hist.Append(context.Background(), ev) - - var buf bytes.Buffer - err := renderHistory(&buf, tmpl, hist) - assert.NoError(t, err) - assert.Contains(t, buf.String(), "Invalid state change payload") - t.Log(buf.String()) - }) - - t.Run("invalid request metadata", func(t *testing.T) { - t.Parallel() - - hist := history.NewRingStore(10) - - // deliberately use a string literal instead of a struct - ev := history.Event{ - Timestamp: time.Now(), - Type: history.EventTypeHeartbeatReceived, - HeartbeatID: "hb5", - RawPayload: json.RawMessage(`"this is not a struct"`), // will not decode into struct - } - _ = hist.Append(context.Background(), ev) - - var buf bytes.Buffer - err := renderHistory(&buf, tmpl, hist) - assert.NoError(t, err) - assert.Contains(t, buf.String(), "Invalid request metadata") - t.Log(buf.String()) - }) - - t.Run("unknown event type", func(t *testing.T) { - t.Parallel() - - hist := history.NewRingStore(10) - ev := history.MustNewEvent(history.EventType("999"), "hb6", nil) - ev.RawPayload = nil - _ = hist.Append(context.Background(), ev) - - var buf bytes.Buffer - err := renderHistory(&buf, tmpl, hist) - assert.NoError(t, err) - assert.Contains(t, buf.String(), "Unknown event type") - }) -} diff --git a/internal/handlers/test.go b/internal/handlers/test.go index 56a8269..bb86df9 100644 --- a/internal/handlers/test.go +++ b/internal/handlers/test.go @@ -4,10 +4,10 @@ import ( "fmt" "log/slog" "net/http" - "time" "github.com/containeroo/heartbeats/internal/heartbeat" "github.com/containeroo/heartbeats/internal/notifier" + testservice "github.com/containeroo/heartbeats/internal/service/test" ) // TestReceiverHandler allows sending a test notification to a specific receiver @@ -19,14 +19,7 @@ func TestReceiverHandler(dispatcher *notifier.Dispatcher, logger *slog.Logger) h return } - logger.Info("Test request received", "receiver", id) - - dispatcher.Mailbox() <- notifier.NotificationData{ - ID: fmt.Sprintf("manual-test-%s", time.Now().Format(time.RFC3339)), - Receivers: []string{id}, - Title: "Test Notification", - Message: "This is a test notification", - } + testservice.SendTestNotification(dispatcher, logger, id) w.WriteHeader(http.StatusOK) fmt.Fprint(w, "ok") // nolint:errcheck @@ -42,9 +35,7 @@ func TestHeartbeatHandler(mgr *heartbeat.Manager, logger *slog.Logger) http.Hand return } - logger.Info("Test request heartbeat", "heartbeat", id) - - if err := mgr.Test(id); err != nil { + if err := testservice.TriggerTestHeartbeat(mgr, logger, id); err != nil { logger.Error("handle test failed", "id", id, "err", err) http.Error(w, err.Error(), http.StatusNotFound) return diff --git a/internal/service/bump/bump.go b/internal/service/bump/bump.go new file mode 100644 index 0000000..61240af --- /dev/null +++ b/internal/service/bump/bump.go @@ -0,0 +1,86 @@ +package bump + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/containeroo/heartbeats/internal/heartbeat" + "github.com/containeroo/heartbeats/internal/history" +) + +// ErrUnknownHeartbeat signals that the heartbeat ID is not registered. +var ErrUnknownHeartbeat = errors.New("unknown heartbeat id") + +// Receive records a heartbeat bump and updates the in-memory state. +func Receive( + ctx context.Context, + mgr *heartbeat.Manager, + hist history.Store, + logger *slog.Logger, + id string, + source string, + method string, + userAgent string, +) error { + if mgr.Get(id) == nil { + logger.Warn("received bump for unknown heartbeat ID", "id", id, "from", source) + return fmt.Errorf("%w: %s", ErrUnknownHeartbeat, id) + } + + logger.Info("received bump", "id", id, "from", source) + + payload := history.RequestMetadataPayload{ + Source: source, + Method: method, + UserAgent: userAgent, + } + ev := history.MustNewEvent(history.EventTypeHeartbeatReceived, id, payload) + + if err := hist.Append(ctx, ev); err != nil { + logger.Error("failed to record state change", "err", err) + return err + } + + // We check if the heartbeat exists before calling Receive. + mgr.Receive(id) // nolint:errcheck + + return nil +} + +// Fail records a manual failure and updates the in-memory state. +func Fail( + ctx context.Context, + mgr *heartbeat.Manager, + hist history.Store, + logger *slog.Logger, + id string, + source string, + method string, + userAgent string, +) error { + if mgr.Get(id) == nil { + logger.Warn("received /fail bump for unknown heartbeat ID", "id", id, "from", source) + return fmt.Errorf("%w: %s", ErrUnknownHeartbeat, id) + } + + logger.Info("manual fail", "id", id, "from", source) + + payload := history.RequestMetadataPayload{ + Source: source, + Method: method, + UserAgent: userAgent, + } + ev := history.MustNewEvent(history.EventTypeHeartbeatFailed, id, payload) + + if err := hist.Append(ctx, ev); err != nil { + logger.Error("failed to record state change", "err", err) + return err + } + + // We check if the heartbeat exists before calling Fail. + mgr.Fail(id) // nolint:errcheck + + return nil +} diff --git a/internal/service/bump/bump_test.go b/internal/service/bump/bump_test.go new file mode 100644 index 0000000..6c91bfe --- /dev/null +++ b/internal/service/bump/bump_test.go @@ -0,0 +1,141 @@ +package bump + +import ( + "context" + "errors" + "log/slog" + "strings" + "testing" + "time" + + "github.com/containeroo/heartbeats/internal/heartbeat" + "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/notifier" + "github.com/stretchr/testify/assert" +) + +func setupManager(t *testing.T, hist history.Store, hbName string) *heartbeat.Manager { + t.Helper() + + logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) + store := notifier.NewReceiverStore() + disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + + cfg := heartbeat.HeartbeatConfigMap{ + hbName: { + ID: hbName, + Description: "desc", + Interval: time.Second, + Grace: time.Second, + Receivers: []string{"r1"}, + }, + } + return heartbeat.NewManagerFromHeartbeatMap(context.Background(), cfg, disp.Mailbox(), hist, logger) +} + +func findEventByType(t *testing.T, events []history.Event, h history.EventType) *history.Event { + t.Helper() + + for i := range events { + if events[i].Type == h { + return &events[i] + } + } + return nil +} + +func TestReceive(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) + hist := history.NewRingStore(10) + mgr := setupManager(t, hist, "hb1") + + err := Receive(context.Background(), mgr, hist, logger, "hb1", "1.2.3.4:5678", "GET", "Go-test") + assert.NoError(t, err) + + events := hist.ListByID("hb1") + ev := findEventByType(t, events, history.EventTypeHeartbeatReceived) + if assert.NotNil(t, ev) { + var meta history.RequestMetadataPayload + assert.NoError(t, ev.DecodePayload(&meta)) + assert.Equal(t, "GET", meta.Method) + assert.Equal(t, "1.2.3.4:5678", meta.Source) + assert.Equal(t, "Go-test", meta.UserAgent) + } +} + +func TestReceiveUnknownHeartbeat(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) + hist := history.NewRingStore(10) + mgr := setupManager(t, hist, "hb1") + + err := Receive(context.Background(), mgr, hist, logger, "missing", "1.2.3.4:5678", "GET", "Go-test") + assert.ErrorIs(t, err, ErrUnknownHeartbeat) +} + +func TestReceiveAppendError(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) + expectedErr := errors.New("append failed") + mockStore := &history.MockStore{ + RecordEventFunc: func(ctx context.Context, e history.Event) error { + return expectedErr + }, + } + mgr := setupManager(t, mockStore, "hb1") + + err := Receive(context.Background(), mgr, mockStore, logger, "hb1", "1.2.3.4:5678", "GET", "Go-test") + assert.ErrorIs(t, err, expectedErr) +} + +func TestFail(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) + hist := history.NewRingStore(10) + mgr := setupManager(t, hist, "hb1") + + err := Fail(context.Background(), mgr, hist, logger, "hb1", "1.2.3.4:5678", "POST", "Go-test") + assert.NoError(t, err) + + events := hist.ListByID("hb1") + ev := findEventByType(t, events, history.EventTypeHeartbeatFailed) + if assert.NotNil(t, ev) { + var meta history.RequestMetadataPayload + assert.NoError(t, ev.DecodePayload(&meta)) + assert.Equal(t, "POST", meta.Method) + assert.Equal(t, "1.2.3.4:5678", meta.Source) + assert.Equal(t, "Go-test", meta.UserAgent) + } +} + +func TestFailUnknownHeartbeat(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) + hist := history.NewRingStore(10) + mgr := setupManager(t, hist, "hb1") + + err := Fail(context.Background(), mgr, hist, logger, "missing", "1.2.3.4:5678", "POST", "Go-test") + assert.ErrorIs(t, err, ErrUnknownHeartbeat) +} + +func TestFailAppendError(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) + expectedErr := errors.New("append failed") + mockStore := &history.MockStore{ + RecordEventFunc: func(ctx context.Context, e history.Event) error { + return expectedErr + }, + } + mgr := setupManager(t, mockStore, "hb1") + + err := Fail(context.Background(), mgr, mockStore, logger, "hb1", "1.2.3.4:5678", "POST", "Go-test") + assert.ErrorIs(t, err, expectedErr) +} diff --git a/internal/service/test/test.go b/internal/service/test/test.go new file mode 100644 index 0000000..9fab073 --- /dev/null +++ b/internal/service/test/test.go @@ -0,0 +1,28 @@ +package test + +import ( + "fmt" + "log/slog" + "time" + + "github.com/containeroo/heartbeats/internal/heartbeat" + "github.com/containeroo/heartbeats/internal/notifier" +) + +// SendTestNotification sends a test notification to a specific receiver. +func SendTestNotification(dispatcher *notifier.Dispatcher, logger *slog.Logger, id string) { + logger.Info("Test request received", "receiver", id) + + dispatcher.Mailbox() <- notifier.NotificationData{ + ID: fmt.Sprintf("manual-test-%s", time.Now().Format(time.RFC3339)), + Receivers: []string{id}, + Title: "Test Notification", + Message: "This is a test notification", + } +} + +// TriggerTestHeartbeat sends a test notification for a specific heartbeat. +func TriggerTestHeartbeat(mgr *heartbeat.Manager, logger *slog.Logger, id string) error { + logger.Info("Test request heartbeat", "heartbeat", id) + return mgr.Test(id) +} diff --git a/internal/service/test/test_test.go b/internal/service/test/test_test.go new file mode 100644 index 0000000..c16337f --- /dev/null +++ b/internal/service/test/test_test.go @@ -0,0 +1,73 @@ +package test + +import ( + "context" + "log/slog" + "strings" + "testing" + "time" + + "github.com/containeroo/heartbeats/internal/heartbeat" + "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/notifier" + "github.com/stretchr/testify/assert" +) + +func TestSendTestNotification(t *testing.T) { + t.Parallel() + + notifyCh := make(chan notifier.NotificationData, 1) + mock := ¬ifier.MockNotifier{ + NotifyFunc: func(ctx context.Context, data notifier.NotificationData) error { + notifyCh <- data + return nil + }, + } + + store := notifier.NewReceiverStore() + store.Register("rec-1", mock) + + logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) + hist := history.NewRingStore(10) + disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + go disp.Run(ctx) + + SendTestNotification(disp, logger, "rec-1") + + select { + case data := <-notifyCh: + assert.Len(t, data.Receivers, 1) + assert.Equal(t, "rec-1", data.Receivers[0]) + assert.Equal(t, "Test Notification", data.Title) + assert.Equal(t, "This is a test notification", data.Message) + assert.True(t, strings.HasPrefix(data.ID, "manual-test-")) + case <-time.After(2 * time.Second): + t.Fatal("timeout waiting for notification") + } +} + +func TestTriggerTestHeartbeat(t *testing.T) { + t.Parallel() + + logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) + hist := history.NewRingStore(10) + store := notifier.NewReceiverStore() + disp := notifier.NewDispatcher(store, logger, hist, 1, 1, 10) + + cfg := heartbeat.HeartbeatConfigMap{ + "hb1": { + ID: "hb1", + Description: "desc", + Interval: time.Second, + Grace: time.Second, + Receivers: []string{"r1"}, + }, + } + mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), cfg, disp.Mailbox(), hist, logger) + + assert.NoError(t, TriggerTestHeartbeat(mgr, logger, "hb1")) + assert.EqualError(t, TriggerTestHeartbeat(mgr, logger, "missing"), "heartbeat ID \"missing\" not found") +} diff --git a/internal/view/partials.go b/internal/view/partials.go new file mode 100644 index 0000000..8ca0336 --- /dev/null +++ b/internal/view/partials.go @@ -0,0 +1,178 @@ +package view + +import ( + "fmt" + "io" + "sort" + "text/template" + "time" + + "github.com/containeroo/heartbeats/internal/heartbeat" + "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/notifier" +) + +type HeartbeatView struct { + ID string + Status string + Description string + Interval string + IntervalSeconds float64 // for table sorting + Grace string + GraceSeconds float64 // for table sorting + LastBump time.Time + URL string // full URL to copy + Receivers []string + HasHistory bool +} + +// ReceiverView for rendering each notifier instance. +type ReceiverView struct { + ID string // receiver ID + Type string // "slack", "email", etc. + Destination string // e.g. channel name, email addrs + LastSent time.Time + LastErr error +} + +// HistoryView is what the template actually sees. +// Details is already formatted for display. +type HistoryView struct { + Timestamp time.Time + Type string // EventType + HeartbeatID string + Details string // e.g. "Notification Sent", "old → new", or blank +} + +// RenderHeartbeats builds HeartbeatView slice, sorts it, and executes the template. +func RenderHeartbeats( + w io.Writer, + tmpl *template.Template, + bumpURL string, + mgr *heartbeat.Manager, + hist history.Store, +) error { + actors := mgr.List() + views := make([]HeartbeatView, 0, len(actors)) + for id, a := range actors { + evs := hist.ListByID(id) + + views = append(views, HeartbeatView{ + ID: id, + Status: a.State.String(), + Description: a.Description, + Interval: a.Interval.String(), + IntervalSeconds: a.Interval.Seconds(), + Grace: a.Grace.String(), + LastBump: a.LastBump, + URL: bumpURL + id, + Receivers: a.Receivers, + HasHistory: len(evs) > 0, + }) + } + // Sort alphabetically by ID for consistent ordering + sort.Slice(views, func(i, j int) bool { return views[i].ID < views[j].ID }) + + data := struct { + Heartbeats []HeartbeatView + }{ + Heartbeats: views, + } + return tmpl.ExecuteTemplate(w, "heartbeats", data) +} + +// RenderReceivers builds ReceiverView slice, sorts it by (ID,Type), and executes the template. +func RenderReceivers( + w io.Writer, + tmpl *template.Template, + disp *notifier.Dispatcher, +) error { + raw := disp.List() + + views := make([]ReceiverView, 0, len(raw)) + for rid, nots := range raw { + for _, n := range nots { + rv := ReceiverView{ + ID: rid, + Type: n.Type(), + Destination: n.Target(), + LastSent: n.LastSent(), + LastErr: n.LastErr(), + } + + views = append(views, rv) + } + } + + // Sort alphabetically by ID for consistent ordering + sort.Slice(views, func(i, j int) bool { return views[i].ID < views[j].ID }) + + data := struct{ Receivers []ReceiverView }{Receivers: views} + + return tmpl.ExecuteTemplate(w, "receivers", data) +} + +// RenderHistory sorts events newest-first, builds the filter list, and executes the template. +func RenderHistory( + w io.Writer, + tmpl *template.Template, + hist history.Store, +) error { + raw := hist.List() + + views := make([]HistoryView, 0, len(raw)) + for _, e := range raw { + var det string + + switch e.Type { + case history.EventTypeNotificationSent, history.EventTypeNotificationFailed: + var p history.NotificationPayload + if err := e.DecodePayload(&p); err == nil { + if p.Error != "" { + det = fmt.Sprintf("Notification to %q via %s (%s) failed: %s", + p.Receiver, p.Type, p.Target, p.Error, + ) + } else { + det = fmt.Sprintf("Notification sent to %q via %s (%s)", + p.Receiver, p.Type, p.Target, + ) + } + } else { + det = "Invalid notification payload" + } + + case history.EventTypeStateChanged: + var p history.StateChangePayload + if err := e.DecodePayload(&p); err == nil { + det = fmt.Sprintf("%s → %s", p.From, p.To) + } else { + det = "Invalid state change payload" + } + + case history.EventTypeHeartbeatReceived, history.EventTypeHeartbeatFailed: + var p history.RequestMetadataPayload + if err := e.DecodePayload(&p); err == nil { + det = fmt.Sprintf("%s from %s with %q", p.Method, p.Source, p.UserAgent) + } else { + det = "Invalid request metadata" + } + + default: + det = "Unknown event type" + } + + views = append(views, HistoryView{ + Timestamp: e.Timestamp, + Type: e.Type.String(), + HeartbeatID: e.HeartbeatID, + Details: det, + }) + } + + // Newest first + sort.Slice(views, func(i, j int) bool { + return views[j].Timestamp.Before(views[i].Timestamp) + }) + + return tmpl.ExecuteTemplate(w, "history", struct{ Events []HistoryView }{Events: views}) +} diff --git a/internal/view/partials_test.go b/internal/view/partials_test.go new file mode 100644 index 0000000..2673fd0 --- /dev/null +++ b/internal/view/partials_test.go @@ -0,0 +1,230 @@ +package view + +import ( + "bytes" + "context" + "encoding/json" + "log/slog" + "strings" + "testing" + "testing/fstest" + "text/template" + "time" + + "github.com/containeroo/heartbeats/internal/heartbeat" + "github.com/containeroo/heartbeats/internal/history" + "github.com/containeroo/heartbeats/internal/notifier" + "github.com/stretchr/testify/assert" +) + +func loadTestTemplate(t *testing.T, name string, content string) *template.Template { + t.Helper() + + fs := fstest.MapFS{ + "web/templates/" + name + ".html": &fstest.MapFile{Data: []byte(content)}, + } + tmpl, err := template.New(name). + Funcs(notifier.FuncMap()). + ParseFS(fs, "web/templates/"+name+".html") + + assert.NoError(t, err) + + return tmpl +} + +func encodePayload(t *testing.T, payload any) json.RawMessage { + t.Helper() + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal payload: %v", err) + } + return data +} + +func TestRenderHeartbeats(t *testing.T) { + t.Parallel() + + tmpl := loadTestTemplate(t, "heartbeats", `{{define "heartbeats"}}{{range .Heartbeats}}{{.ID}}:{{.Status}};{{end}}{{end}}`) + + hist := history.NewRingStore(10) + logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) + store := notifier.InitializeStore(nil, false, "0.0.0", logger) + disp := notifier.NewDispatcher(store, nil, hist, 1, 1, 10) + mgr := heartbeat.NewManagerFromHeartbeatMap(context.Background(), map[string]heartbeat.HeartbeatConfig{ + "b": { + Description: "b-desc", + Interval: 1 * time.Second, + Grace: 1 * time.Second, + Receivers: []string{"r1"}, + }, + "a": { + Description: "a-desc", + Interval: 1 * time.Second, + Grace: 1 * time.Second, + Receivers: []string{"r1"}, + }, + }, disp.Mailbox(), hist, nil) + + var buf bytes.Buffer + a := mgr.Get("b") + a.LastBump = time.Now() + + err := RenderHeartbeats(&buf, tmpl, "http://localhost", mgr, hist) + assert.NoError(t, err) + assert.Contains(t, buf.String(), "a:idle;b:idle") +} + +func TestRenderReceivers(t *testing.T) { + t.Parallel() + + tmpl := loadTestTemplate(t, "receivers", `{{define "receivers"}}{{range .Receivers}}{{.Type}};{{end}}{{end}}`) + + r := notifier.ReceiverConfig{ + SlackConfigs: []notifier.SlackConfig{{Channel: "channel"}}, + MSTeamsConfigs: []notifier.MSTeamsConfig{{WebhookURL: "example.com"}}, + EmailConfigs: []notifier.EmailConfig{{EmailDetails: notifier.EmailDetails{To: []string{"to"}}}}, + } + rc := map[string]notifier.ReceiverConfig{"r": r} + + logger := slog.New(slog.NewTextHandler(&strings.Builder{}, nil)) + store := notifier.InitializeStore(rc, false, "0.0.0", logger) + disp := notifier.NewDispatcher(store, nil, nil, 1, 1, 10) + + var buf bytes.Buffer + err := RenderReceivers(&buf, tmpl, disp) + assert.NoError(t, err) + assert.Equal(t, buf.String(), "slack;email;msteams;") +} + +func TestRenderHistory(t *testing.T) { + t.Parallel() + + tmpl := loadTestTemplate(t, "history", `{{define "history"}}{{range .Events}}{{.Type}}:{{.Details}};{{end}}{{end}}`) + + hist := history.NewRingStore(10) + + // Record state change event + _ = hist.Append(context.Background(), history.MustNewEvent( + history.EventTypeStateChanged, + "hb1", + history.StateChangePayload{ + From: "missing", + To: "received", + }, + )) + + // Record notification event + _ = hist.Append(context.Background(), history.MustNewEvent( + history.EventTypeNotificationSent, + "hb1", + history.NotificationPayload{ + Receiver: "r1", + Type: "mock-type", + Target: "mock-target", + }, + )) + + var buf bytes.Buffer + err := RenderHistory(&buf, tmpl, hist) + + assert.NoError(t, err) + assert.Equal(t, + "NotificationSent:Notification sent to \"r1\" via mock-type (mock-target);StateChanged:missing → received;", + buf.String(), + ) + + t.Run("notification failed", func(t *testing.T) { + t.Parallel() + + hist := history.NewRingStore(10) + _ = hist.Append(context.Background(), history.MustNewEvent( + history.EventTypeNotificationFailed, + "hb2", + history.NotificationPayload{ + Receiver: "r2", + Type: "mock-type", + Target: "mock-target", + Error: "fail!", + }, + )) + + var buf bytes.Buffer + err := RenderHistory(&buf, tmpl, hist) + assert.NoError(t, err) + assert.Contains(t, buf.String(), `Notification to "r2" via mock-type (mock-target) failed: fail!`) + }) + + t.Run("invalid notification payload", func(t *testing.T) { + t.Parallel() + + hist := history.NewRingStore(10) + ev := history.Event{ + Timestamp: time.Now(), + Type: history.EventTypeNotificationSent, + HeartbeatID: "hb3", + RawPayload: json.RawMessage(`{invalid-json}`), // deliberately broken + } + _ = hist.Append(context.Background(), ev) + + var buf bytes.Buffer + err := RenderHistory(&buf, tmpl, hist) + assert.NoError(t, err) + assert.Contains(t, buf.String(), "Invalid notification payload") + }) + + t.Run("invalid state change", func(t *testing.T) { + t.Parallel() + + hist := history.NewRingStore(10) + + // Force an invalid payload: a raw string is not a struct + ev := history.Event{ + Timestamp: time.Now(), + Type: history.EventTypeStateChanged, + HeartbeatID: "hb4", + RawPayload: encodePayload(t, "not a valid state change"), + } + _ = hist.Append(context.Background(), ev) + + var buf bytes.Buffer + err := RenderHistory(&buf, tmpl, hist) + assert.NoError(t, err) + assert.Contains(t, buf.String(), "Invalid state change payload") + t.Log(buf.String()) + }) + + t.Run("invalid request metadata", func(t *testing.T) { + t.Parallel() + + hist := history.NewRingStore(10) + + // deliberately use a string literal instead of a struct + ev := history.Event{ + Timestamp: time.Now(), + Type: history.EventTypeHeartbeatReceived, + HeartbeatID: "hb5", + RawPayload: encodePayload(t, "this is not a struct"), + } + _ = hist.Append(context.Background(), ev) + + var buf bytes.Buffer + err := RenderHistory(&buf, tmpl, hist) + assert.NoError(t, err) + assert.Contains(t, buf.String(), "Invalid request metadata") + t.Log(buf.String()) + }) + + t.Run("unknown event type", func(t *testing.T) { + t.Parallel() + + hist := history.NewRingStore(10) + ev := history.MustNewEvent(history.EventType("999"), "hb6", nil) + ev.RawPayload = nil + _ = hist.Append(context.Background(), ev) + + var buf bytes.Buffer + err := RenderHistory(&buf, tmpl, hist) + assert.NoError(t, err) + assert.Contains(t, buf.String(), "Unknown event type") + }) +}