Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
10 changes: 6 additions & 4 deletions internal/app/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
33 changes: 18 additions & 15 deletions internal/flag/flag.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
Expand Down Expand Up @@ -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
Expand Down
56 changes: 12 additions & 44 deletions internal/handlers/bump.go
Original file line number Diff line number Diff line change
@@ -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}.
Expand All @@ -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
})
Expand All @@ -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
})
Expand Down
176 changes: 4 additions & 172 deletions internal/handlers/partials.go
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -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})
}
Loading