Merge pull request 'Phase 3 — Alerts (P3-05/06/07)' (#7) from p3-alerts into main

Reviewed-on: #7
This commit is contained in:
2026-05-04 21:51:16 +00:00
49 changed files with 4940 additions and 48 deletions
+5
View File
@@ -30,3 +30,8 @@ coverage.html
# skips paths beginning with _ or ., but ignore explicitly so nothing
# checked in here can leak into a release tarball.
/_diag/
# Dev-only one-shot binaries (cmd/_*) — never shipped. Go's build
# tooling already skips paths starting with _, but ignore explicitly
# so an accidental `git add cmd/.` can't sneak them into a release.
/cmd/_*/
+21 -9
View File
@@ -12,8 +12,10 @@ import (
"syscall"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/alert"
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
"gitea.dcglab.co.uk/steve/restic-manager/internal/notification"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
rmhttp "gitea.dcglab.co.uk/steve/restic-manager/internal/server/http"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/maintenance"
@@ -82,19 +84,24 @@ func run() error {
hub := ws.NewHub()
jobHub := ws.NewJobHub()
notifHub := notification.NewHub(st, aead, cfg.BaseURL)
alertEngine := alert.NewEngine(st, notifHub)
renderer, err := ui.New()
if err != nil {
return fmt.Errorf("ui: %w", err)
}
deps := rmhttp.Deps{
Cfg: cfg,
Store: st,
AEAD: aead,
Hub: hub,
JobHub: jobHub,
UI: renderer,
Version: version,
Cfg: cfg,
Store: st,
AEAD: aead,
Hub: hub,
JobHub: jobHub,
AlertEngine: alertEngine,
NotificationHub: notifHub,
UI: renderer,
Version: version,
}
// First-run bootstrap: if the users table is empty, mint a one-time
@@ -126,6 +133,8 @@ func run() error {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
go alertEngine.Run(ctx)
errCh := make(chan error, 1)
go func() {
slog.Info("server listening", "addr", cfg.Listen, "version", version)
@@ -175,8 +184,11 @@ func run() error {
}
case <-offlineTick.C:
cutoff := time.Now().Add(-90 * time.Second)
if n, err := st.MarkHostsOfflineStale(ctx, cutoff); err == nil && n > 0 {
slog.Info("marked hosts offline (stale heartbeat)", "n", n)
if ids, err := st.MarkHostsOfflineStaleReturnIDs(ctx, cutoff); err == nil && len(ids) > 0 {
slog.Info("marked hosts offline (stale heartbeat)", "n", len(ids))
for _, id := range ids {
alertEngine.NotifyHostOffline(id)
}
}
case <-pendingDrainTick.C:
srv.DrainAllDue(ctx)
+205
View File
@@ -0,0 +1,205 @@
// Package alert evaluates the hardcoded rule set and persists raises
// / acknowledges / resolves. Three event sources feed it:
// - JobFinishedEvent — pushed when a job lands a terminal state
// (the existing MarkJobFinished site)
// - HostOfflineEvent / HostOnlineEvent — pushed by the offline
// sweeper and by the ws hello handler
// - 60s ticker (internal) — drives stale-schedule + auto-resolve
//
// All output goes through store.RaiseOrTouch / Acknowledge / Resolve
// and the notification.Hub. The engine is one goroutine started at
// boot; non-blocking sends from hot paths.
package alert
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/notification"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// JobFinishedEvent carries everything the engine needs to evaluate
// the failed-X rules. Pushed via Engine.NotifyJobFinished from the
// MarkJobFinished site.
type JobFinishedEvent struct {
HostID string
JobID string
Kind string // backup | forget | prune | check | unlock | restore | diff
Status string // succeeded | failed | cancelled
When time.Time
}
// Engine evaluates hardcoded alert rules and dispatches via notification.Hub.
type Engine struct {
store *store.Store
hub *notification.Hub
jobs chan JobFinishedEvent
hostDown chan string // host_id
hostUp chan string
// agentOfflineFloor is the duration a host must be offline before
// we raise. Configurable for tests; default 15m.
agentOfflineFloor time.Duration
tickPeriod time.Duration
closeOnce sync.Once
done chan struct{}
}
// NewEngine builds the engine. agentOfflineFloor + tickPeriod default
// to 15min and 60s respectively when zero.
func NewEngine(st *store.Store, hub *notification.Hub) *Engine {
return &Engine{
store: st,
hub: hub,
jobs: make(chan JobFinishedEvent, 32),
hostDown: make(chan string, 32),
hostUp: make(chan string, 32),
agentOfflineFloor: 15 * time.Minute,
tickPeriod: 60 * time.Second,
done: make(chan struct{}),
}
}
// Run drives the event loop. Returns when ctx is done. Blocks; call in
// its own goroutine.
func (e *Engine) Run(ctx context.Context) {
t := time.NewTicker(e.tickPeriod)
defer t.Stop()
for {
select {
case <-ctx.Done():
e.closeOnce.Do(func() { close(e.done) })
return
case ev := <-e.jobs:
e.handleJobFinished(ctx, ev)
case hostID := <-e.hostDown:
e.handleHostOffline(ctx, hostID)
case hostID := <-e.hostUp:
e.handleHostOnline(ctx, hostID)
case now := <-t.C:
e.tick(ctx, now)
}
}
}
// NotifyJobFinished is the hot-path hook called from MarkJobFinished's
// caller (ws.handler.dispatchAgentMessage). Non-blocking: drops on a
// full channel with a slog warning.
func (e *Engine) NotifyJobFinished(ev JobFinishedEvent) {
select {
case e.jobs <- ev:
default:
slog.Warn("alert: jobs channel full; dropping event", "kind", ev.Kind, "host_id", ev.HostID)
}
}
// NotifyHostOffline notifies the engine that a host is offline.
func (e *Engine) NotifyHostOffline(hostID string) {
select {
case e.hostDown <- hostID:
default:
slog.Warn("alert: hostDown channel full; dropping", "host_id", hostID)
}
}
// NotifyHostOnline notifies the engine that a host is online.
func (e *Engine) NotifyHostOnline(hostID string) {
select {
case e.hostUp <- hostID:
default:
slog.Warn("alert: hostUp channel full; dropping", "host_id", hostID)
}
}
func (e *Engine) handleJobFinished(ctx context.Context, ev JobFinishedEvent) {
// Determine which kind/severity pair this job maps to. Jobs not
// listed here (init, unlock, restore, diff) produce no alerts in v1.
var kind, severity string
switch ev.Kind {
case "backup":
kind, severity = KindBackupFailed, "warning"
case "forget":
kind, severity = KindForgetFailed, "warning"
case "prune":
kind, severity = KindPruneFailed, "warning"
case "check":
kind, severity = KindCheckFailed, "critical"
default:
return
}
switch ev.Status {
case "failed":
e.raiseAndNotify(ctx, ev.HostID, kind, severity,
fmt.Sprintf("%s job %s failed", ev.Kind, ev.JobID), ev.When)
case "succeeded":
e.resolveAndNotify(ctx, ev.HostID, kind, ev.When)
}
}
func (e *Engine) handleHostOffline(ctx context.Context, hostID string) {
host, err := e.store.GetHost(ctx, hostID)
if err != nil {
return
}
// Apply the 15-min floor — raise only when last_seen_at is older
// than agentOfflineFloor. A nil last_seen_at (host enrolled but
// never connected) is treated as "now" so we don't raise
// immediately on enrolment.
if host.LastSeenAt == nil {
return
}
if time.Since(*host.LastSeenAt) < e.agentOfflineFloor {
return
}
e.raiseAndNotify(ctx, hostID, KindAgentOffline, "warning",
fmt.Sprintf("Agent offline for %s (threshold %s)",
roundDur(time.Since(*host.LastSeenAt)), e.agentOfflineFloor),
time.Now().UTC())
}
func (e *Engine) handleHostOnline(ctx context.Context, hostID string) {
e.resolveAndNotify(ctx, hostID, KindAgentOffline, time.Now().UTC())
}
// tick is the 60-second sweep. Responsibilities:
// 1. Re-evaluate agent_offline for every offline host that may have
// crossed the floor between explicit events.
// 2. Stale-schedule detection — declared in the spec but intentionally
// left as a no-op in v1. The precise "expected to have fired but
// didn't" trigger requires a store helper that lands in a later
// task. The KindStaleSchedule constant is exported so UI code can
// reference the tag string today.
func (e *Engine) tick(ctx context.Context, now time.Time) {
hosts, err := e.store.ListHosts(ctx)
if err != nil {
slog.Warn("alert: tick list hosts", "err", err)
return
}
for _, h := range hosts {
if h.Status != "offline" || h.LastSeenAt == nil {
continue
}
if now.Sub(*h.LastSeenAt) >= e.agentOfflineFloor {
e.raiseAndNotify(ctx, h.ID, KindAgentOffline, "warning",
fmt.Sprintf("Agent offline for %s (threshold %s)",
roundDur(now.Sub(*h.LastSeenAt)), e.agentOfflineFloor), now)
}
}
// Stale-schedule sweep — no-op in v1. See KindStaleSchedule doc comment.
}
// roundDur returns a human-readable duration string, rounding to the
// nearest minute. Durations under a minute are reported as "less than
// a minute".
func roundDur(d time.Duration) string {
if d < time.Minute {
return "less than a minute"
}
return d.Round(time.Minute).String()
}
+164
View File
@@ -0,0 +1,164 @@
package alert
import (
"context"
"fmt"
"log/slog"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/notification"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// Alert kind constants — keep in lockstep with the engine logic and
// the UI tag-colour table.
const (
// KindBackupFailed is raised when a backup job finishes with
// status "failed" and resolved on next backup success.
KindBackupFailed = "backup_failed"
// KindForgetFailed mirrors KindBackupFailed for forget jobs.
KindForgetFailed = "forget_failed"
// KindPruneFailed mirrors KindBackupFailed for prune jobs.
KindPruneFailed = "prune_failed"
// KindCheckFailed is raised at "critical" severity (repository
// integrity is at risk) when a check job fails.
KindCheckFailed = "check_failed"
// KindStaleSchedule is declared for completeness but intentionally
// left as a no-op in v1. The precise "expected to have fired but
// didn't" logic requires a store helper that lands in a follow-up
// task. Ask the team before implementing.
KindStaleSchedule = "stale_schedule"
// KindAgentOffline is raised when a host's last_seen_at is older
// than the 15-minute floor and resolved when the host reconnects.
KindAgentOffline = "agent_offline"
)
// raiseAndNotify is the standard raise pattern: store.RaiseOrTouch
// deduplicates, and notification.Hub.Dispatch fires only on the first
// raise (didRaise=true). Subsequent occurrences of the same open alert
// are "touched" (last_seen_at bumped) without a second notification.
func (e *Engine) raiseAndNotify(ctx context.Context, hostID, kind, severity, message string, when time.Time) {
id, didRaise, err := e.store.RaiseOrTouch(ctx, hostID, kind, severity, message, when)
if err != nil {
slog.Warn("alert: raise", "kind", kind, "host_id", hostID, "err", err)
return
}
if !didRaise {
return
}
host, err := e.store.GetHost(ctx, hostID)
hostName := hostID
if err == nil {
hostName = host.Name
}
go e.hub.Dispatch(ctx, notification.Payload{
Event: notification.EventRaised,
AlertID: id,
Severity: severity,
Kind: kind,
HostID: hostID,
HostName: hostName,
Message: message,
RaisedAt: when,
})
}
// Acknowledge updates the alert row and fans out alert.acknowledged to
// every enabled channel. Best-effort: store errors are logged but the
// dispatch still fires only when the store update succeeds.
func (e *Engine) Acknowledge(ctx context.Context, alertID, userID string, when time.Time) error {
if err := e.store.Acknowledge(ctx, alertID, userID, when); err != nil {
return err
}
a, lerr := e.store.GetAlert(ctx, alertID)
if lerr != nil || a == nil {
// Acknowledge already succeeded; dispatch is best-effort.
return nil //nolint:nilerr
}
p := alertPayload(ctx, e.store, notification.EventAcknowledged, a)
go e.hub.Dispatch(context.WithoutCancel(ctx), p)
return nil
}
// Resolve marks the alert resolved and fans out alert.resolved.
func (e *Engine) Resolve(ctx context.Context, alertID string, when time.Time) error {
a, _ := e.store.GetAlert(ctx, alertID)
if err := e.store.Resolve(ctx, alertID, when); err != nil {
return err
}
if a == nil {
return nil
}
p := alertPayload(ctx, e.store, notification.EventResolved, a)
go e.hub.Dispatch(context.WithoutCancel(ctx), p)
return nil
}
// alertPayload builds a Payload from a stored Alert, looking up the host
// name when HostID is set.
func alertPayload(ctx context.Context, st *store.Store, ev notification.Event, a *store.Alert) notification.Payload {
hostID, hostName := "", ""
if a.HostID != nil {
hostID = *a.HostID
hostName = hostID
if h, err := st.GetHost(ctx, hostID); err == nil && h != nil {
hostName = h.Name
}
}
return notification.Payload{
Event: ev,
AlertID: a.ID,
Severity: a.Severity,
Kind: a.Kind,
HostID: hostID,
HostName: hostName,
Message: a.Message,
RaisedAt: a.CreatedAt,
}
}
// resolveAndNotify clears every open (or acknowledged) alert for
// (host_id, kind) via store.AutoResolve, then fires alert.resolved
// for each row that was actually open. Best-effort — errors are
// logged but do not propagate.
func (e *Engine) resolveAndNotify(ctx context.Context, hostID, kind string, when time.Time) {
open, err := e.store.ListAlerts(ctx, store.AlertFilter{
Status: "open", HostID: hostID,
})
if err != nil {
return
}
openAcked, _ := e.store.ListAlerts(ctx, store.AlertFilter{
Status: "acknowledged", HostID: hostID,
})
all := append(open, openAcked...)
if err := e.store.AutoResolve(ctx, hostID, kind, when); err != nil {
slog.Warn("alert: auto-resolve", "kind", kind, "host_id", hostID, "err", err)
return
}
host, _ := e.store.GetHost(ctx, hostID)
hostName := hostID
if host != nil {
hostName = host.Name
}
for _, a := range all {
if a.Kind != kind {
continue
}
go e.hub.Dispatch(ctx, notification.Payload{
Event: notification.EventResolved,
AlertID: a.ID,
Severity: a.Severity,
Kind: a.Kind,
HostID: hostID,
HostName: hostName,
Message: fmt.Sprintf("Auto-resolved (%s)", kind),
RaisedAt: when,
})
}
}
+125
View File
@@ -0,0 +1,125 @@
package alert
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
"gitea.dcglab.co.uk/steve/restic-manager/internal/notification"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
func setupEngine(t *testing.T) (*Engine, *store.Store, string) {
t.Helper()
dir := t.TempDir()
st, _ := store.Open(context.Background(), filepath.Join(dir, "rm.db"))
t.Cleanup(func() { _ = st.Close() })
keyPath := filepath.Join(dir, "secret.key")
_ = crypto.GenerateKeyFile(keyPath)
key, _ := crypto.LoadKeyFromFile(keyPath)
aead, _ := crypto.NewAEAD(key)
hub := notification.NewHub(st, aead, "https://rm.example")
eng := NewEngine(st, hub)
hostID := ulid.Make().String()
if err := st.CreateHost(context.Background(), store.Host{
ID: hostID, Name: "alfa-01", OS: "linux", Arch: "amd64",
EnrolledAt: time.Now().UTC(),
}, "deadbeef", ""); err != nil {
t.Fatalf("create host: %v", err)
}
return eng, st, hostID
}
func TestEngineBackupFailedRaisesThenResolves(t *testing.T) {
t.Parallel()
eng, st, hostID := setupEngine(t)
ctx := context.Background()
eng.handleJobFinished(ctx, JobFinishedEvent{
HostID: hostID, JobID: "j1", Kind: "backup", Status: "failed",
When: time.Now().UTC(),
})
open, _ := st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
if len(open) != 1 || open[0].Kind != KindBackupFailed {
t.Fatalf("expected one backup_failed open; got %+v", open)
}
// Second failed job should TOUCH (not raise a fresh row).
eng.handleJobFinished(ctx, JobFinishedEvent{
HostID: hostID, JobID: "j2", Kind: "backup", Status: "failed",
When: time.Now().UTC().Add(time.Minute),
})
open, _ = st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
if len(open) != 1 {
t.Fatalf("expected dedup to stay at 1 open; got %d", len(open))
}
// Success auto-resolves.
eng.handleJobFinished(ctx, JobFinishedEvent{
HostID: hostID, JobID: "j3", Kind: "backup", Status: "succeeded",
When: time.Now().UTC().Add(2 * time.Minute),
})
open, _ = st.ListAlerts(ctx, store.AlertFilter{Status: "open", HostID: hostID})
if len(open) != 0 {
t.Fatalf("expected zero open after success; got %d", len(open))
}
}
func TestEngineCheckFailedSeverityCritical(t *testing.T) {
t.Parallel()
eng, st, hostID := setupEngine(t)
eng.handleJobFinished(context.Background(), JobFinishedEvent{
HostID: hostID, Kind: "check", Status: "failed", When: time.Now().UTC(),
})
open, _ := st.ListAlerts(context.Background(),
store.AlertFilter{Status: "open", HostID: hostID})
if len(open) != 1 || open[0].Severity != "critical" {
t.Fatalf("got %+v", open)
}
}
func TestEngineAgentOfflineRespects15MinFloor(t *testing.T) {
t.Parallel()
eng, st, hostID := setupEngine(t)
// Host's last_seen_at defaulted to NULL via CreateHost (enrolled but never
// seen). Force a stale value for the test by direct DB update.
if _, err := st.DB().Exec(
`UPDATE hosts SET last_seen_at = ? WHERE id = ?`,
time.Now().UTC().Add(-20*time.Minute).Format(time.RFC3339Nano), hostID,
); err != nil {
t.Fatalf("update last_seen_at: %v", err)
}
eng.handleHostOffline(context.Background(), hostID)
open, _ := st.ListAlerts(context.Background(),
store.AlertFilter{Status: "open", HostID: hostID})
if len(open) != 1 {
t.Fatalf("expected agent_offline raised; got %d", len(open))
}
// Bring back online — should auto-resolve.
eng.handleHostOnline(context.Background(), hostID)
open, _ = st.ListAlerts(context.Background(),
store.AlertFilter{Status: "open", HostID: hostID})
if len(open) != 0 {
t.Fatalf("expected agent_offline resolved; got %d", len(open))
}
}
func TestEngineAgentOfflineUnderFloorNoRaise(t *testing.T) {
t.Parallel()
eng, st, hostID := setupEngine(t)
// last_seen_at is NULL from CreateHost (never touched). A nil
// last_seen_at means the host was enrolled but never connected —
// treat that as "now" for the floor check so we don't raise
// immediately. handleHostOffline must skip the raise.
eng.handleHostOffline(context.Background(), hostID)
open, _ := st.ListAlerts(context.Background(),
store.AlertFilter{Status: "open", HostID: hostID})
if len(open) != 0 {
t.Fatalf("expected no raise within 15-min floor; got %d", len(open))
}
}
+20
View File
@@ -0,0 +1,20 @@
package notification
import (
"context"
"time"
)
// Channel is the per-kind transport. Implementations live in
// webhook.go / ntfy.go / smtp.go. Send must respect ctx (5s for HTTP,
// 10s for SMTP) and never panic.
type Channel interface {
// Kind returns the kind string ("webhook", "ntfy", "smtp"). Used
// for log enrichment and dispatcher routing.
Kind() string
// Send delivers one payload. Returns (statusCode, latency, err).
// statusCode is HTTP for HTTP channels, the SMTP final-line code
// (e.g. 250) for SMTP, 0 if the call didn't reach a wire response.
Send(ctx context.Context, p Payload) (statusCode int, latency time.Duration, err error)
}
+187
View File
@@ -0,0 +1,187 @@
package notification
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"sync"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// Hub fans Payload events out to every enabled channel and persists
// the result to notification_log. One Hub per process; thread-safe.
type Hub struct {
store *store.Store
aead *crypto.AEAD
baseURL string // e.g. https://restic-manager.example
msgIDDomain string // hostname extracted from baseURL for SMTP Message-ID
}
// NewHub constructs a Hub. baseURL is the public root of the server
// (used to build /alerts/<id> links and the SMTP Message-ID domain).
func NewHub(st *store.Store, aead *crypto.AEAD, baseURL string) *Hub {
return &Hub{
store: st,
aead: aead,
baseURL: baseURL,
msgIDDomain: extractDomain(baseURL),
}
}
// Dispatch fans out to every enabled channel. Best-effort — failures
// are logged to notification_log but do not propagate to the caller.
// Each channel runs in its own goroutine; Dispatch returns only when
// all goroutines have settled, so the caller can block briefly for
// the test-button case.
func (h *Hub) Dispatch(ctx context.Context, p Payload) {
chans, err := h.store.ListEnabledNotificationChannels(ctx)
if err != nil {
slog.Error("notification: list channels", "err", err)
return
}
// Stamp the alert link if the caller left it empty.
if p.Link == "" {
p.Link = h.baseURL + "/alerts/" + p.AlertID
}
var wg sync.WaitGroup
for _, c := range chans {
wg.Add(1)
go func(c store.NotificationChannel) {
defer wg.Done()
h.send(ctx, c, p)
}(c)
}
wg.Wait()
}
// DispatchOne fires a single channel — used by the "Send test
// notification" button. Returns the log entry that was persisted so
// the handler can render the result inline.
func (h *Hub) DispatchOne(ctx context.Context, channelID string, p Payload) (store.NotificationLogEntry, error) {
c, err := h.store.GetNotificationChannel(ctx, channelID)
if err != nil {
return store.NotificationLogEntry{}, err
}
if p.Link == "" {
p.Link = h.baseURL + "/alerts/" + p.AlertID
}
return h.send(ctx, *c, p), nil
}
// send builds the channel impl, delivers the payload, and persists a
// notification_log row regardless of success or failure.
func (h *Hub) send(ctx context.Context, c store.NotificationChannel, p Payload) store.NotificationLogEntry {
ch, buildErr := h.buildChannel(c)
logEntry := store.NotificationLogEntry{
ID: newID(),
ChannelID: c.ID,
Event: string(p.Event),
FiredAt: time.Now().UTC(),
}
if p.AlertID != "" {
aid := p.AlertID
logEntry.AlertID = &aid
}
if buildErr != nil {
errStr := buildErr.Error()
logEntry.OK = false
logEntry.Error = &errStr
_ = h.store.AppendNotificationLog(ctx, logEntry)
return logEntry
}
code, latency, sendErr := ch.Send(ctx, p)
statusCode := code
latencyMS := int(latency.Milliseconds())
logEntry.StatusCode = &statusCode
logEntry.LatencyMS = &latencyMS
if sendErr != nil {
errStr := sendErr.Error()
logEntry.OK = false
logEntry.Error = &errStr
} else {
logEntry.OK = true
}
if err := h.store.AppendNotificationLog(ctx, logEntry); err != nil {
slog.Warn("notification: persist log", "err", err)
}
return logEntry
}
// buildChannel decrypts the channel config and returns a concrete
// Channel implementation for the channel's kind.
func (h *Hub) buildChannel(row store.NotificationChannel) (Channel, error) {
plain, err := h.aead.Decrypt(string(row.Config), []byte("notification-channel:"+row.ID))
if err != nil {
return nil, err
}
switch row.Kind {
case "webhook":
var cfg WebhookConfig
if err := json.Unmarshal(plain, &cfg); err != nil {
return nil, err
}
return NewWebhookChannel(cfg), nil
case "ntfy":
var cfg NtfyConfig
if err := json.Unmarshal(plain, &cfg); err != nil {
return nil, err
}
dp := ""
if row.DefaultPriority != nil {
dp = *row.DefaultPriority
}
return NewNtfyChannel(cfg, dp), nil
case "smtp":
var cfg SMTPConfig
if err := json.Unmarshal(plain, &cfg); err != nil {
return nil, err
}
return NewSMTPChannel(cfg, h.msgIDDomain), nil
}
return nil, errUnknownKind(row.Kind)
}
// newID returns a 32-hex-char random identifier for notification_log rows.
func newID() string {
var b [16]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// extractDomain strips the scheme and path from baseURL, leaving only
// the host[:port] component. Used as the right-hand side of SMTP
// Message-IDs.
func extractDomain(baseURL string) string {
s := baseURL
if i := indexOf(s, "://"); i >= 0 {
s = s[i+3:]
}
if i := indexOf(s, "/"); i >= 0 {
s = s[:i]
}
if s == "" {
return "restic-manager.local"
}
return s
}
// indexOf returns the index of the first occurrence of sub in s, or -1.
func indexOf(s, sub string) int {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return i
}
}
return -1
}
type errUnknownKind string
func (e errUnknownKind) Error() string { return "notification: unknown kind: " + string(e) }
+99
View File
@@ -0,0 +1,99 @@
package notification
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"time"
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
func setupHub(t *testing.T) (*Hub, *store.Store) {
t.Helper()
dir := t.TempDir()
st, err := store.Open(context.Background(), filepath.Join(dir, "rm.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
keyPath := filepath.Join(dir, "secret.key")
_ = crypto.GenerateKeyFile(keyPath)
key, _ := crypto.LoadKeyFromFile(keyPath)
aead, _ := crypto.NewAEAD(key)
return NewHub(st, aead, "https://rm.example"), st
}
func TestHubDispatchRecordsLogEntries(t *testing.T) {
t.Parallel()
hub, st := setupHub(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(200)
}))
defer srv.Close()
cfg, _ := json.Marshal(WebhookConfig{URL: srv.URL})
enc, err := hub.aead.Encrypt(cfg, []byte("notification-channel:test-ch"))
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if err := st.CreateNotificationChannel(context.Background(), store.NotificationChannel{
ID: "test-ch", Kind: "webhook", Name: "test", Enabled: true,
Config: []byte(enc), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("create channel: %v", err)
}
hub.Dispatch(context.Background(), Payload{
Event: EventRaised,
Severity: "warning",
Kind: "backup_failed",
HostName: "alfa-01",
Message: "x",
RaisedAt: time.Now().UTC(),
})
// Verify a log row landed with ok=1.
var n int
if err := st.DB().QueryRow(
`SELECT COUNT(*) FROM notification_log WHERE channel_id = ? AND ok = 1`, "test-ch",
).Scan(&n); err != nil {
t.Fatalf("count: %v", err)
}
if n != 1 {
t.Fatalf("expected 1 log row, got %d", n)
}
}
func TestHubSkipsDisabledChannels(t *testing.T) {
t.Parallel()
hub, st := setupHub(t)
cfg, _ := json.Marshal(WebhookConfig{URL: "http://no-such-host.invalid"})
enc, _ := hub.aead.Encrypt(cfg, []byte("notification-channel:dis"))
_ = st.CreateNotificationChannel(context.Background(), store.NotificationChannel{
ID: "dis", Kind: "webhook", Name: "off", Enabled: false,
Config: []byte(enc), CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
})
hub.Dispatch(context.Background(), Payload{
Event: EventRaised,
AlertID: "x",
Severity: "warning",
Kind: "backup_failed",
HostName: "h",
Message: "m",
RaisedAt: time.Now().UTC(),
})
var n int
_ = st.DB().QueryRow(`SELECT COUNT(*) FROM notification_log`).Scan(&n)
if n != 0 {
t.Errorf("disabled channel produced log rows: %d", n)
}
}
+115
View File
@@ -0,0 +1,115 @@
package notification
import (
"bytes"
"context"
"encoding/base64"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// NtfyConfig is the per-channel JSON shape stored AEAD-encrypted in
// notification_channels.config. AccessToken takes precedence over
// (Username, Password) when both are set; supply one or the other for
// self-hosted ntfy that requires auth.
type NtfyConfig struct {
ServerURL string `json:"server_url"`
Topic string `json:"topic"`
AccessToken string `json:"access_token,omitempty"`
Username string `json:"username,omitempty"`
Password string `json:"password,omitempty"`
}
// NtfyChannel delivers alerts to an ntfy server using POST with
// ntfy-specific headers (Title, Priority, Tags, Click). One instance
// per configured channel row. Reused across sends — http.Client is
// goroutine-safe.
type NtfyChannel struct {
cfg NtfyConfig
defaultPriority string // "min"/"low"/"default"/"high"/"urgent" or ""
client *http.Client
}
// NewNtfyChannel builds an ntfy channel with a 5s http.Client timeout.
// defaultPriority is the channel-configured fallback when no
// severity-specific mapping applies; pass "" to use the built-in
// fallbacks (4 for warning, 3 for everything else).
func NewNtfyChannel(cfg NtfyConfig, defaultPriority string) *NtfyChannel {
if cfg.ServerURL == "" {
cfg.ServerURL = "https://ntfy.sh"
}
return &NtfyChannel{
cfg: cfg,
defaultPriority: defaultPriority,
client: &http.Client{Timeout: 5 * time.Second},
}
}
// Kind returns "ntfy" for log enrichment and dispatcher routing.
func (c *NtfyChannel) Kind() string { return "ntfy" }
// Send delivers the payload as a plain-text POST to <server>/<topic>
// with ntfy headers. Returns (statusCode, latency, err). 4xx/5xx
// responses are returned as errors with the status code set.
func (c *NtfyChannel) Send(ctx context.Context, p Payload) (int, time.Duration, error) {
server := strings.TrimRight(c.cfg.ServerURL, "/")
url := server + "/" + c.cfg.Topic
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewBufferString(p.Message))
if err != nil {
return 0, 0, fmt.Errorf("ntfy: build request: %w", err)
}
req.Header.Set("Content-Type", "text/plain")
req.Header.Set("Title", fmt.Sprintf("[%s] %s %s", p.Severity, p.HostName, p.Kind))
req.Header.Set("Tags", p.Severity+","+p.Kind)
req.Header.Set("Priority", priorityForSeverity(p.Severity, c.defaultPriority))
if p.Link != "" {
req.Header.Set("Click", p.Link)
}
switch {
case c.cfg.AccessToken != "":
req.Header.Set("Authorization", "Bearer "+c.cfg.AccessToken)
case c.cfg.Username != "":
creds := c.cfg.Username + ":" + c.cfg.Password
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(creds)))
}
t0 := time.Now()
res, err := c.client.Do(req)
latency := time.Since(t0)
if err != nil {
return 0, latency, fmt.Errorf("ntfy: do: %w", err)
}
defer func() { _ = res.Body.Close() }()
// Drain body to keep the connection reusable.
_, _ = io.Copy(io.Discard, res.Body)
if res.StatusCode >= 400 {
return res.StatusCode, latency, fmt.Errorf("ntfy: http %d", res.StatusCode)
}
return res.StatusCode, latency, nil
}
// priorityForSeverity maps a severity string to an ntfy numeric priority
// string. critical always returns "5" regardless of defaultPri. For
// other severities, defaultPri is returned when non-empty, otherwise
// "4" for warning and "3" for everything else.
func priorityForSeverity(severity, defaultPri string) string {
switch severity {
case "critical":
return "5"
case "warning":
if defaultPri != "" {
return defaultPri
}
return "4"
default:
if defaultPri != "" {
return defaultPri
}
return "3"
}
}
+97
View File
@@ -0,0 +1,97 @@
package notification
import (
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestNtfySendsHeadersAndBody(t *testing.T) {
t.Parallel()
var (
gotTitle string
gotPri string
gotTags string
gotClick string
gotAuth string
gotContentType string
gotBody string
)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotTitle = r.Header.Get("Title")
gotPri = r.Header.Get("Priority")
gotTags = r.Header.Get("Tags")
gotClick = r.Header.Get("Click")
gotAuth = r.Header.Get("Authorization")
gotContentType = r.Header.Get("Content-Type")
b, _ := io.ReadAll(r.Body)
gotBody = string(b)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
cfg := NtfyConfig{
ServerURL: srv.URL,
Topic: "alerts",
AccessToken: "tk1",
}
ch := NewNtfyChannel(cfg, "") // no default priority; critical must still be "5"
p := Payload{
Event: EventRaised,
AlertID: "01HZ",
Severity: "critical",
Kind: "check_failed",
HostName: "alfa-01",
Message: "errors found",
RaisedAt: time.Now(),
Link: "https://rm.example/a",
}
code, _, err := ch.Send(t.Context(), p)
if err != nil {
t.Fatalf("Send: %v", err)
}
if code != http.StatusOK {
t.Fatalf("want 200, got %d", code)
}
if want := "[critical] alfa-01 check_failed"; gotTitle != want {
t.Errorf("Title: got %q want %q", gotTitle, want)
}
if gotPri != "5" {
t.Errorf("Priority: got %q want \"5\"", gotPri)
}
if want := "critical,check_failed"; gotTags != want {
t.Errorf("Tags: got %q want %q", gotTags, want)
}
if gotClick != "https://rm.example/a" {
t.Errorf("Click: got %q want %q", gotClick, "https://rm.example/a")
}
if want := "Bearer tk1"; gotAuth != want {
t.Errorf("Authorization: got %q want %q", gotAuth, want)
}
if gotContentType != "text/plain" {
t.Errorf("Content-Type: got %q want %q", gotContentType, "text/plain")
}
if gotBody != "errors found" {
t.Errorf("body: got %q want %q", gotBody, "errors found")
}
}
func TestNtfyDefaultPriorityRespected(t *testing.T) {
t.Parallel()
// info + defaultPri="min" → "min"
if got := priorityForSeverity("info", "min"); got != "min" {
t.Errorf("info+min: got %q want \"min\"", got)
}
// critical → "5" regardless of default
if got := priorityForSeverity("critical", "min"); got != "5" {
t.Errorf("critical+min: got %q want \"5\"", got)
}
}
+36
View File
@@ -0,0 +1,36 @@
// Package notification owns the fan-out of alert events to operator-
// configured channels. Three channels in v1: webhook, ntfy, smtp.
// Each channel implements Channel.Send for one Payload at a time;
// the Hub orchestrates fan-out, persists to notification_log.
package notification
import "time"
// Event identifies the lifecycle hook this notification is for.
type Event string
const (
// EventRaised occurs when an alert is first raised.
EventRaised Event = "alert.raised"
// EventAcknowledged occurs when an alert is acknowledged.
EventAcknowledged Event = "alert.acknowledged"
// EventResolved occurs when an alert is resolved.
EventResolved Event = "alert.resolved"
// EventTest is used for test notifications.
EventTest Event = "alert.test"
)
// Payload is the per-event blob every channel renders into its own
// shape. Severity maps to channel-specific priority (ntfy) or stays
// in the body (webhook/smtp).
type Payload struct {
Event Event // alert.raised | … | alert.test
AlertID string // ULID
Severity string // info | warning | critical
Kind string // backup_failed | …
HostID string
HostName string
Message string
RaisedAt time.Time
Link string // Absolute URL to /alerts/<id>; built by Hub
}
+140
View File
@@ -0,0 +1,140 @@
package notification
import (
"context"
"crypto/tls"
"fmt"
"net"
"net/smtp"
"strings"
"time"
)
// SMTPConfig holds the configuration for an SMTP notification channel.
type SMTPConfig struct {
Host string `json:"host"`
Port int `json:"port"`
Encryption string `json:"encryption"` // "starttls" | "tls" | "none"
Username string `json:"username"`
Password string `json:"password"`
From string `json:"from"`
To string `json:"to"`
}
// SMTPChannel delivers alert notifications via plain-text email.
type SMTPChannel struct {
cfg SMTPConfig
// messageIDDomain holds the public base hostname of restic-manager so
// Message-IDs include a stable right-hand-side. Falls back to
// "restic-manager.local" when unset.
messageIDDomain string
}
// NewSMTPChannel builds an SMTP channel. messageIDDomain comes from
// cfg.Cfg.BaseURL — caller passes it through.
func NewSMTPChannel(cfg SMTPConfig, messageIDDomain string) *SMTPChannel {
if messageIDDomain == "" {
messageIDDomain = "restic-manager.local"
}
return &SMTPChannel{cfg: cfg, messageIDDomain: messageIDDomain}
}
// Kind returns "smtp".
func (c *SMTPChannel) Kind() string { return "smtp" }
// Send delivers the payload as a plain-text email via SMTP.
// Returns (250, latency, nil) on success.
func (c *SMTPChannel) Send(ctx context.Context, p Payload) (int, time.Duration, error) {
t0 := time.Now()
addr := fmt.Sprintf("%s:%d", c.cfg.Host, c.cfg.Port)
// Dial respects ctx (we use net.Dialer).
dialer := &net.Dialer{Timeout: 10 * time.Second}
rawConn, err := dialer.DialContext(ctx, "tcp", addr)
if err != nil {
return 0, time.Since(t0), fmt.Errorf("smtp: dial %s: %w", addr, err)
}
var client *smtp.Client
switch strings.ToLower(c.cfg.Encryption) {
case "tls":
conn := tls.Client(rawConn, &tls.Config{ServerName: c.cfg.Host, MinVersion: tls.VersionTLS12})
client, err = smtp.NewClient(conn, c.cfg.Host)
case "starttls", "":
client, err = smtp.NewClient(rawConn, c.cfg.Host)
if err == nil {
err = client.StartTLS(&tls.Config{ServerName: c.cfg.Host, MinVersion: tls.VersionTLS12})
}
case "none":
client, err = smtp.NewClient(rawConn, c.cfg.Host)
default:
_ = rawConn.Close()
return 0, time.Since(t0), fmt.Errorf("smtp: unknown encryption %q", c.cfg.Encryption)
}
if err != nil {
_ = rawConn.Close()
return 0, time.Since(t0), fmt.Errorf("smtp: handshake: %w", err)
}
defer func() { _ = client.Quit() }()
if c.cfg.Username != "" {
auth := smtp.PlainAuth("", c.cfg.Username, c.cfg.Password, c.cfg.Host)
if err := client.Auth(auth); err != nil {
return 0, time.Since(t0), fmt.Errorf("smtp: auth: %w", err)
}
}
if err := client.Mail(extractAddr(c.cfg.From)); err != nil {
return 0, time.Since(t0), fmt.Errorf("smtp: MAIL FROM: %w", err)
}
if err := client.Rcpt(c.cfg.To); err != nil {
return 0, time.Since(t0), fmt.Errorf("smtp: RCPT TO: %w", err)
}
wc, err := client.Data()
if err != nil {
return 0, time.Since(t0), fmt.Errorf("smtp: DATA: %w", err)
}
msg := buildEmailBody(c.cfg, c.messageIDDomain, p)
if _, err := wc.Write(msg); err != nil {
return 0, time.Since(t0), fmt.Errorf("smtp: write: %w", err)
}
if err := wc.Close(); err != nil {
return 0, time.Since(t0), fmt.Errorf("smtp: close DATA: %w", err)
}
return 250, time.Since(t0), nil
}
// extractAddr pulls the bare email out of a "Name <addr@host>" form.
func extractAddr(s string) string {
if i, j := strings.LastIndex(s, "<"), strings.LastIndex(s, ">"); i >= 0 && j > i {
return s[i+1 : j]
}
return s
}
// buildEmailBody assembles the RFC 5322 message bytes per the spec.
// Plain text only; subject hardcoded.
func buildEmailBody(cfg SMTPConfig, msgIDDomain string, p Payload) []byte {
var b strings.Builder
b.WriteString("From: " + cfg.From + "\r\n")
b.WriteString("To: " + cfg.To + "\r\n")
b.WriteString(fmt.Sprintf("Subject: [restic-manager] [%s] %s: %s\r\n", p.Severity, p.HostName, p.Kind))
b.WriteString("Date: " + p.RaisedAt.UTC().Format(time.RFC1123Z) + "\r\n")
b.WriteString("Message-ID: <" + p.AlertID + "@" + msgIDDomain + ">\r\n")
b.WriteString("MIME-Version: 1.0\r\n")
b.WriteString("Content-Type: text/plain; charset=utf-8\r\n")
b.WriteString("\r\n")
b.WriteString(p.Message + "\r\n\r\n")
b.WriteString("—\r\n")
b.WriteString("Raised at: " + p.RaisedAt.UTC().Format(time.RFC3339) + "\r\n")
b.WriteString("Severity: " + p.Severity + "\r\n")
b.WriteString("Host: " + p.HostName + "\r\n")
b.WriteString("Kind: " + p.Kind + "\r\n")
if p.Link != "" {
b.WriteString("\r\nOpen in restic-manager:\r\n")
b.WriteString(p.Link + "\r\n")
}
b.WriteString("\r\n(This message was sent by restic-manager. Acknowledge or resolve in the UI.)\r\n")
return []byte(b.String())
}
+154
View File
@@ -0,0 +1,154 @@
package notification
import (
"context"
"net"
"strings"
"sync"
"testing"
"time"
)
// fakeSMTPServer accepts a single connection, runs the minimal SMTP
// dialogue (HELO/EHLO, MAIL FROM, RCPT TO, DATA, QUIT) and stores
// what came across the wire. Plain (no TLS) — we test the protocol
// shape, not crypto.
type fakeSMTPServer struct {
mu sync.Mutex
mailFrom string
rcptTo string
data string
authed bool
}
func startFakeSMTP(t *testing.T) (string, *fakeSMTPServer) {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
srv := &fakeSMTPServer{}
t.Cleanup(func() { _ = ln.Close() })
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer func() { _ = conn.Close() }()
readLine := func() string {
buf := make([]byte, 1024)
n, err := conn.Read(buf)
if err != nil {
return ""
}
return string(buf[:n])
}
write := func(s string) { _, _ = conn.Write([]byte(s)) }
write("220 fake.smtp ESMTP\r\n")
for {
line := readLine()
if line == "" {
return
}
cmd := strings.ToUpper(strings.TrimSpace(line))
switch {
case strings.HasPrefix(cmd, "EHLO"), strings.HasPrefix(cmd, "HELO"):
write("250-fake.smtp\r\n250 AUTH PLAIN\r\n")
case strings.HasPrefix(cmd, "AUTH "):
srv.mu.Lock()
srv.authed = true
srv.mu.Unlock()
write("235 OK\r\n")
case strings.HasPrefix(cmd, "MAIL FROM"):
srv.mu.Lock()
srv.mailFrom = strings.TrimSpace(strings.TrimPrefix(line, "MAIL FROM:"))
srv.mu.Unlock()
write("250 OK\r\n")
case strings.HasPrefix(cmd, "RCPT TO"):
srv.mu.Lock()
srv.rcptTo = strings.TrimSpace(strings.TrimPrefix(line, "RCPT TO:"))
srv.mu.Unlock()
write("250 OK\r\n")
case cmd == "DATA":
write("354 OK\r\n")
// read until "\r\n.\r\n"
var data strings.Builder
for {
chunk := readLine()
if chunk == "" {
break
}
data.WriteString(chunk)
if strings.Contains(data.String(), "\r\n.\r\n") {
break
}
}
srv.mu.Lock()
srv.data = data.String()
srv.mu.Unlock()
write("250 OK\r\n")
case cmd == "QUIT":
write("221 bye\r\n")
return
default:
write("500 unknown\r\n")
}
}
}()
return ln.Addr().String(), srv
}
func TestSMTPSendsExpectedHeaders(t *testing.T) {
t.Parallel()
addr, srv := startFakeSMTP(t)
host, port := splitHostPort(addr)
ch := NewSMTPChannel(SMTPConfig{
Host: host, Port: port, Encryption: "none",
Username: "u", Password: "p",
From: "Restic-Manager <alerts@example.com>",
To: "ops@example.com",
}, "rm.example")
_, _, err := ch.Send(context.Background(), Payload{
Event: EventRaised, AlertID: "01ABC",
Severity: "warning", Kind: "backup_failed",
HostName: "alfa-01", Message: "Backup failed: 401",
RaisedAt: time.Date(2026, 5, 4, 15, 42, 1, 0, time.UTC),
Link: "https://rm.example/alerts/01ABC",
})
if err != nil {
t.Fatalf("send: %v", err)
}
srv.mu.Lock()
defer srv.mu.Unlock()
if !srv.authed {
t.Errorf("AUTH never sent")
}
if !strings.Contains(srv.mailFrom, "alerts@example.com") {
t.Errorf("MAIL FROM: %q", srv.mailFrom)
}
if !strings.Contains(srv.rcptTo, "ops@example.com") {
t.Errorf("RCPT TO: %q", srv.rcptTo)
}
if !strings.Contains(srv.data, "Subject: [restic-manager] [warning] alfa-01: backup_failed") {
t.Errorf("subject missing or wrong: %q", srv.data)
}
if !strings.Contains(srv.data, "Message-ID: <01ABC@rm.example>") {
t.Errorf("Message-ID wrong: %q", srv.data)
}
if !strings.Contains(srv.data, "Backup failed: 401") {
t.Errorf("body missing: %q", srv.data)
}
}
func splitHostPort(addr string) (string, int) {
host, portStr, _ := net.SplitHostPort(addr)
var port int
for _, r := range portStr {
port = port*10 + int(r-'0')
}
return host, port
}
+98
View File
@@ -0,0 +1,98 @@
package notification
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
)
// WebhookConfig is the per-channel JSON shape stored AEAD-encrypted
// in notification_channels.config.
type WebhookConfig struct {
URL string `json:"url"`
BearerToken string `json:"bearer_token,omitempty"`
HeaderName string `json:"header_name,omitempty"`
HeaderValue string `json:"header_value,omitempty"`
}
// WebhookChannel is the HTTP-POST channel. One per configured channel
// row. Reused across sends — the http.Client is goroutine-safe.
type WebhookChannel struct {
cfg WebhookConfig
client *http.Client
}
// NewWebhookChannel builds a webhook with a 5s overall timeout enforced
// by the http.Client; ctx in Send is layered on top for caller-driven
// cancel.
func NewWebhookChannel(cfg WebhookConfig) *WebhookChannel {
return &WebhookChannel{
cfg: cfg,
client: &http.Client{Timeout: 5 * time.Second},
}
}
// Kind returns "webhook" for log enrichment and dispatcher routing.
func (c *WebhookChannel) Kind() string { return "webhook" }
// webhookBody is the wire-stable envelope. Documented in the spec; do
// not reorder fields freely — operators write switch statements on
// "event" and "severity".
type webhookBody struct {
Event string `json:"event"`
AlertID string `json:"alert_id"`
Severity string `json:"severity"`
Kind string `json:"kind"`
HostID string `json:"host_id"`
HostName string `json:"host_name"`
Message string `json:"message"`
RaisedAt string `json:"raised_at"`
Link string `json:"link"`
}
// Send delivers the payload as a JSON POST. Returns (statusCode, latency, err).
// 4xx/5xx responses are returned as errors with the status code set.
func (c *WebhookChannel) Send(ctx context.Context, p Payload) (int, time.Duration, error) {
body := webhookBody{
Event: string(p.Event), AlertID: p.AlertID,
Severity: p.Severity, Kind: p.Kind,
HostID: p.HostID, HostName: p.HostName,
Message: p.Message,
RaisedAt: p.RaisedAt.UTC().Format(time.RFC3339Nano),
Link: p.Link,
}
buf, err := json.Marshal(body)
if err != nil {
return 0, 0, fmt.Errorf("webhook: marshal body: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.cfg.URL, bytes.NewReader(buf))
if err != nil {
return 0, 0, fmt.Errorf("webhook: build request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
if c.cfg.BearerToken != "" {
req.Header.Set("Authorization", "Bearer "+c.cfg.BearerToken)
}
if c.cfg.HeaderName != "" {
req.Header.Set(c.cfg.HeaderName, c.cfg.HeaderValue)
}
t0 := time.Now()
res, err := c.client.Do(req)
latency := time.Since(t0)
if err != nil {
return 0, latency, fmt.Errorf("webhook: do: %w", err)
}
defer func() { _ = res.Body.Close() }()
// Drain body — keep the connection reusable.
_, _ = io.Copy(io.Discard, res.Body)
if res.StatusCode >= 400 {
return res.StatusCode, latency, fmt.Errorf("webhook: http %d", res.StatusCode)
}
return res.StatusCode, latency, nil
}
+83
View File
@@ -0,0 +1,83 @@
package notification
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestWebhookSendsCorrectPayloadAndHeaders(t *testing.T) {
t.Parallel()
var got webhookBody
var auth, custom string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth = r.Header.Get("Authorization")
custom = r.Header.Get("X-Test")
_ = json.NewDecoder(r.Body).Decode(&got)
w.WriteHeader(http.StatusOK)
}))
defer srv.Close()
ch := NewWebhookChannel(WebhookConfig{
URL: srv.URL, BearerToken: "tok-123",
HeaderName: "X-Test", HeaderValue: "yes",
})
code, _, err := ch.Send(context.Background(), Payload{
Event: EventRaised, AlertID: "01K",
Severity: "warning", Kind: "backup_failed",
HostID: "h1", HostName: "alfa-01",
Message: "Backup failed",
RaisedAt: time.Date(2026, 5, 4, 15, 42, 1, 0, time.UTC),
Link: "https://rm.example/alerts/01K",
})
if err != nil {
t.Fatalf("send: %v", err)
}
if code != 200 {
t.Errorf("status: %d", code)
}
if got.Event != "alert.raised" || got.Kind != "backup_failed" || got.Message != "Backup failed" {
t.Errorf("body: %+v", got)
}
if auth != "Bearer tok-123" {
t.Errorf("auth: %q", auth)
}
if custom != "yes" {
t.Errorf("custom header: %q", custom)
}
}
func TestWebhookReturnsErrorOn4xx(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusUnauthorized)
}))
defer srv.Close()
ch := NewWebhookChannel(WebhookConfig{URL: srv.URL})
code, _, err := ch.Send(context.Background(), Payload{Event: EventRaised})
if err == nil {
t.Fatal("expected error for 401")
}
if code != 401 {
t.Errorf("code: %d", code)
}
}
func TestWebhookRespectsCtxTimeout(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
time.Sleep(2 * time.Second)
w.WriteHeader(200)
}))
defer srv.Close()
ch := NewWebhookChannel(WebhookConfig{URL: srv.URL})
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
_, _, err := ch.Send(ctx, Payload{Event: EventRaised})
if err == nil {
t.Fatal("expected timeout error")
}
}
+30
View File
@@ -13,7 +13,9 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"gitea.dcglab.co.uk/steve/restic-manager/internal/alert"
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
"gitea.dcglab.co.uk/steve/restic-manager/internal/notification"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
@@ -29,6 +31,13 @@ type Deps struct {
Hub *ws.Hub
JobHub *ws.JobHub
UI *ui.Renderer
// AlertEngine (optional, wired in G1) receives job-finished and
// host-online events from the WS handler. Nil until G1 constructs
// the engine at boot.
AlertEngine *alert.Engine
// NotificationHub (optional, wired in G1) is used by the test-fire
// endpoint to dispatch a single synthetic payload through a channel.
NotificationHub *notification.Hub
// Version is the binary's build version, surfaced in the chrome.
// Empty falls back to "dev".
Version string
@@ -194,6 +203,13 @@ func (s *Server) routes(r chi.Router) {
// Snapshot diff (P3-09). Dispatches a JobDiff against two
// snapshots; output streams to the standard live job page.
r.Post("/hosts/{id}/snapshots/diff", s.handleSnapshotDiff)
// Alert list (JSON variant). Same filter shape as the UI page.
r.Get("/alerts", s.handleAPIAlerts)
// Notification channel test-fire. Dispatches a synthetic payload
// through a single named channel; returns JSON result.
r.Post("/notifications/{id}/test", s.handleAPINotificationTest)
})
// HTMX form variant of diff (mounted outside /api so HTMX forms
@@ -225,6 +241,7 @@ func (s *Server) routes(r chi.Router) {
Hub: s.deps.Hub,
Store: s.deps.Store,
JobHub: s.deps.JobHub,
AlertEngine: s.deps.AlertEngine,
OnHello: s.onAgentHello,
OnScheduleAck: s.applyScheduleAck,
OnScheduleFire: s.dispatchScheduledJob,
@@ -296,6 +313,19 @@ func (s *Server) routes(r chi.Router) {
r.Get("/hosts/{id}/snapshots/{sid}/restore", s.handleUIRestoreGet)
r.Post("/hosts/{id}/restore", s.handleUIRestorePost)
r.Get("/hosts/{id}/restore/tree", s.handleUIRestoreTree)
// Alerts list + operator actions.
r.Get("/alerts", s.handleUIAlerts)
r.Post("/alerts/{id}/acknowledge", s.handleUIAlertAcknowledge)
r.Post("/alerts/{id}/resolve", s.handleUIAlertResolve)
// Settings shell + Notifications sub-tab CRUD.
r.Get("/settings", s.handleUISettings)
r.Get("/settings/notifications", s.handleUINotificationsList)
r.Get("/settings/notifications/new", s.handleUINotificationNewGet)
r.Post("/settings/notifications/new", s.handleUINotificationNewPost)
r.Get("/settings/notifications/{id}/edit", s.handleUINotificationEditGet)
r.Post("/settings/notifications/{id}/edit", s.handleUINotificationEditPost)
r.Post("/settings/notifications/{id}/delete", s.handleUINotificationDelete)
r.Post("/settings/notifications/{id}/toggle", s.handleUINotificationToggle)
}
// Browser job-log stream (separate from /ws/agent so the auth
+177
View File
@@ -0,0 +1,177 @@
package http
import (
"encoding/json"
"log/slog"
stdhttp "net/http"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
type alertsPage struct {
Filter store.AlertFilter
Alerts []store.Alert
Counts alertCounts
HostNames map[string]string // host_id → name for table rendering
}
type alertCounts struct {
Open int
Acknowledged int
Resolved24h int
}
// handleUIAlerts renders the alerts page with the chosen filters.
func (s *Server) handleUIAlerts(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
q := r.URL.Query()
f := store.AlertFilter{
Status: q.Get("status"),
Severity: q.Get("severity"),
HostID: q.Get("host_id"),
Search: strings.TrimSpace(q.Get("q")),
Limit: 200,
}
if f.Status == "" {
f.Status = "open"
}
alerts, err := s.deps.Store.ListAlerts(r.Context(), f)
if err != nil {
slog.Error("ui alerts: list", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
page := alertsPage{Filter: f, Alerts: alerts, HostNames: map[string]string{}}
if hosts, err := s.deps.Store.ListHosts(r.Context()); err == nil {
for _, h := range hosts {
page.HostNames[h.ID] = h.Name
}
}
page.Counts = computeAlertCounts(s, r)
view := s.baseView(r, u)
view.Title = "Alerts · restic-manager"
view.Active = "alerts"
view.Page = page
if err := s.deps.UI.Render(w, "alerts", view); err != nil {
slog.Error("ui alerts: render", "err", err)
}
}
func computeAlertCounts(s *Server, r *stdhttp.Request) alertCounts {
open, _ := s.deps.Store.ListAlerts(r.Context(),
store.AlertFilter{Status: "open"})
acked, _ := s.deps.Store.ListAlerts(r.Context(),
store.AlertFilter{Status: "acknowledged"})
cutoff := time.Now().UTC().Add(-24 * time.Hour)
all, _ := s.deps.Store.ListAlerts(r.Context(),
store.AlertFilter{Status: "resolved"})
res := 0
for _, a := range all {
if a.ResolvedAt != nil && a.ResolvedAt.After(cutoff) {
res++
}
}
return alertCounts{Open: len(open), Acknowledged: len(acked), Resolved24h: res}
}
// handleAPIAlerts is the JSON list — same filter shape.
func (s *Server) handleAPIAlerts(w stdhttp.ResponseWriter, r *stdhttp.Request) {
if _, ok := s.requireUser(r); !ok {
writeJSONError(w, stdhttp.StatusUnauthorized, "unauthorised", "")
return
}
q := r.URL.Query()
f := store.AlertFilter{
Status: q.Get("status"),
Severity: q.Get("severity"),
HostID: q.Get("host_id"),
Search: strings.TrimSpace(q.Get("q")),
Limit: 200,
}
alerts, err := s.deps.Store.ListAlerts(r.Context(), f)
if err != nil {
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(alerts)
}
// handleUIAlertAcknowledge is POST /alerts/{id}/acknowledge.
func (s *Server) handleUIAlertAcknowledge(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
id := chi.URLParam(r, "id")
if id == "" {
stdhttp.Error(w, "missing id", stdhttp.StatusBadRequest)
return
}
var err error
if s.deps.AlertEngine != nil {
err = s.deps.AlertEngine.Acknowledge(r.Context(), id, u.ID, time.Now().UTC())
} else {
err = s.deps.Store.Acknowledge(r.Context(), id, u.ID, time.Now().UTC())
}
if err != nil {
slog.Warn("ui alerts: ack", "err", err)
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
Action: "alert.acknowledge",
TargetKind: ptr("alert"), TargetID: &id,
TS: time.Now().UTC(),
})
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/alerts?"+r.URL.RawQuery)
w.WriteHeader(stdhttp.StatusNoContent)
return
}
stdhttp.Redirect(w, r, "/alerts", stdhttp.StatusSeeOther)
}
// handleUIAlertResolve is POST /alerts/{id}/resolve.
func (s *Server) handleUIAlertResolve(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
id := chi.URLParam(r, "id")
if id == "" {
stdhttp.Error(w, "missing id", stdhttp.StatusBadRequest)
return
}
var err error
if s.deps.AlertEngine != nil {
err = s.deps.AlertEngine.Resolve(r.Context(), id, time.Now().UTC())
} else {
err = s.deps.Store.Resolve(r.Context(), id, time.Now().UTC())
}
if err != nil {
slog.Warn("ui alerts: resolve", "err", err)
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(), UserID: &u.ID, Actor: "user",
Action: "alert.resolve",
TargetKind: ptr("alert"), TargetID: &id,
TS: time.Now().UTC(),
})
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("HX-Redirect", "/alerts?"+r.URL.RawQuery)
w.WriteHeader(stdhttp.StatusNoContent)
return
}
stdhttp.Redirect(w, r, "/alerts", stdhttp.StatusSeeOther)
}
+41
View File
@@ -0,0 +1,41 @@
package http
import (
"context"
"encoding/json"
stdhttp "net/http"
"testing"
"time"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
func TestAPIAlertsListsOpen(t *testing.T) {
t.Parallel()
srv, ts, st := rawTestServer(t)
hostID, _ := enrolHostForWS(t, srv, st, "host-alerts")
_, _, _ = st.RaiseOrTouch(context.Background(), hostID,
"backup_failed", "warning", "x", time.Now().UTC())
cookie := loginAsAdmin(t, st)
req, _ := stdhttp.NewRequest("GET", ts.URL+"/api/alerts?status=open", nil)
req.AddCookie(cookie)
res, err := stdhttp.DefaultClient.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer res.Body.Close()
if res.StatusCode != 200 {
t.Fatalf("status: %d", res.StatusCode)
}
var got []store.Alert
if err := json.NewDecoder(res.Body).Decode(&got); err != nil {
t.Fatalf("decode: %v", err)
}
if len(got) != 1 || got[0].Kind != "backup_failed" {
t.Fatalf("got %+v", got)
}
_ = ulid.Make() // import keep
}
+35 -17
View File
@@ -89,12 +89,24 @@ func (s *Server) requireUIUser(w stdhttp.ResponseWriter, r *stdhttp.Request) *ui
// authenticated page. Every UI page sits under the dashboard primary
// nav today; if a future page lives under a different primary nav
// tab (e.g. Settings, Audit), accept an Active arg again.
func (s *Server) baseView(u *ui.User) ui.ViewData {
return ui.ViewData{
//
// OpenAlerts is populated via a quick store count so the nav badge
// stays current on every page load without requiring a page-specific
// store call.
func (s *Server) baseView(r *stdhttp.Request, u *ui.User) ui.ViewData {
view := ui.ViewData{
User: u,
Active: "dashboard",
Version: s.version(),
}
// Populate OpenAlerts from the store so the nav badge shows the
// current count on every page.
if open, err := s.deps.Store.ListAlerts(r.Context(), store.AlertFilter{Status: "open"}); err == nil {
view.OpenAlerts = len(open)
}
return view
}
// version returns the binary's build version — passed in via Deps so
@@ -110,10 +122,11 @@ func (s *Server) version() string {
// dashboardPage is the data the dashboard template renders against.
type dashboardPage struct {
Hosts []dashboardHostRow
HostCount int
Summary store.FleetSummary
PendingHosts []store.PendingHost // announce-and-approve queue (P2-18d)
Hosts []dashboardHostRow
HostCount int
Summary store.FleetSummary
PendingHosts []store.PendingHost // announce-and-approve queue (P2-18d)
CritOpenCount int
}
// dashboardHostRow carries a host plus the per-row Run-now decision
@@ -227,13 +240,18 @@ func (s *Server) handleUIDashboard(w stdhttp.ResponseWriter, r *stdhttp.Request)
slog.Warn("ui dashboard: list pending hosts", "err", perr)
}
view := s.baseView(u)
view.OpenAlerts = summary.OpenAlerts
critOpenCount := 0
if crit, err := s.deps.Store.ListAlerts(r.Context(), store.AlertFilter{Status: "open", Severity: "critical"}); err == nil {
critOpenCount = len(crit)
}
view := s.baseView(r, u)
view.Page = dashboardPage{
Hosts: rows,
HostCount: len(hosts),
Summary: summary,
PendingHosts: pending,
Hosts: rows,
HostCount: len(hosts),
Summary: summary,
PendingHosts: pending,
CritOpenCount: critOpenCount,
}
if err := s.deps.UI.Render(w, "dashboard", view); err != nil {
slog.Error("ui: render dashboard", "err", err)
@@ -295,7 +313,7 @@ func (s *Server) handleUIAddHostGet(w stdhttp.ResponseWriter, r *stdhttp.Request
if u == nil {
return
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = "Add host · restic-manager"
view.Page = addHostPage{ServerURL: s.publicURL(r)}
if err := s.deps.UI.Render(w, "add_host", view); err != nil {
@@ -367,7 +385,7 @@ func (s *Server) handleUIAddHostPost(w stdhttp.ResponseWriter, r *stdhttp.Reques
}
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = "Add host · restic-manager"
view.Page = page
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
@@ -434,7 +452,7 @@ func (s *Server) handleUIPendingHost(w stdhttp.ResponseWriter, r *stdhttp.Reques
}
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = "Pending host · restic-manager"
view.Page = page
if err := s.deps.UI.Render(w, "pending_host", view); err != nil {
@@ -612,7 +630,7 @@ func (s *Server) handleUIHostDetail(w stdhttp.ResponseWriter, r *stdhttp.Request
shown = shown[:cap]
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = host.Name + " · restic-manager"
view.Page = hostDetailPage{
hostChromeData: s.loadHostChrome(r, *host, "snapshots", "snapshots"),
@@ -712,7 +730,7 @@ func (s *Server) handleUIJobDetail(w stdhttp.ResponseWriter, r *stdhttp.Request)
nextSeq = logs[n-1].Seq
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = job.Kind + " · " + host.Name + " · restic-manager"
view.Page = jobDetailPage{
Job: *job,
+793
View File
@@ -0,0 +1,793 @@
// ui_notifications.go — HTML form-driven handlers for the notification
// channel CRUD at /settings/notifications and the test-fire endpoint at
// POST /api/notifications/{id}/test.
//
// The settings shell currently has a single sub-tab (Notifications);
// the structure is designed to be extended with Users/Auth tabs later.
//
// Routes (wired in server.go):
//
// GET /settings → handleUISettings
// GET /settings/notifications → handleUINotificationsList
// GET /settings/notifications/new → handleUINotificationNewGet
// POST /settings/notifications/new → handleUINotificationNewPost
// GET /settings/notifications/{id}/edit → handleUINotificationEditGet
// POST /settings/notifications/{id}/edit → handleUINotificationEditPost
// POST /settings/notifications/{id}/delete → handleUINotificationDelete
// POST /api/notifications/{id}/test → handleAPINotificationTest
package http
import (
"encoding/json"
"errors"
"fmt"
"log/slog"
stdhttp "net/http"
"net/mail"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/notification"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// ── page models ──────────────────────────────────────────────────────────────
// settingsPage is the data fed to the settings shell template. The
// sub-tab body is embedded via the Channels slice so a single template
// layout works for both the list and the edit form.
type settingsPage struct {
// ActiveTab is the settings sub-tab currently visible.
ActiveTab string
// Channels is the full list (list sub-tab).
Channels []store.NotificationChannel
// Form is populated when the operator is creating or editing a channel.
Form *notificationForm
// FormError is an inline error message for the channel form.
FormError string
// DeleteError is an inline error shown on the confirm-delete form.
DeleteError string
}
// notificationForm holds the round-trip values for the channel
// create/edit form. Separate per-kind sub-structs mirror the template
// field groups; all fields are strings so the template never has to
// handle nil.
type notificationForm struct {
// ID is the channel's ULID; empty for new.
ID string
Kind string // webhook | ntfy | smtp
Name string
// Enabled maps to the enabled checkbox.
Enabled bool
// DefaultPriority applies to ntfy channels.
DefaultPriority string
// Webhook sub-fields.
WebhookURL string
WebhookBearerToken string
WebhookHeaderName string
WebhookHeaderValue string
// Ntfy sub-fields.
NtfyServerURL string
NtfyTopic string
NtfyAccessToken string
NtfyUsername string
NtfyPassword string
// SMTP sub-fields.
SMTPHost string
SMTPPort string // string for form round-trip; validated to int on save
SMTPEncryption string
SMTPUsername string
// SMTPPassword is a write-only field: shown as placeholder on edit;
// blank on submit means "keep the stored value".
SMTPPassword string
SMTPFrom string
SMTPTo string
}
// ── internal helpers ──────────────────────────────────────────────────────────
// loadSettingsPage fetches the channel list and returns the base page model.
func (s *Server) loadSettingsPage(r *stdhttp.Request) (*settingsPage, error) {
chans, err := s.deps.Store.ListNotificationChannels(r.Context())
if err != nil {
return nil, fmt.Errorf("list channels: %w", err)
}
return &settingsPage{
ActiveTab: "notifications",
Channels: chans,
}, nil
}
// renderSettingsPage renders the settings shell, setting HTTP 422 on
// validation failure (pass status=0 for the normal 200).
func (s *Server) renderSettingsPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, page *settingsPage, status int) {
view := s.baseView(r, u)
view.Title = "Settings · restic-manager"
view.Active = "settings"
view.Page = *page
if status != 0 {
w.WriteHeader(status)
}
if err := s.deps.UI.Render(w, "settings", view); err != nil {
slog.Error("ui: render settings", "err", err)
}
}
// encryptChannelConfig JSON-encodes cfg and AEAD-seals it with the
// channel-specific additional-data binding.
func (s *Server) encryptChannelConfig(id string, cfg any) ([]byte, error) {
plain, err := json.Marshal(cfg)
if err != nil {
return nil, fmt.Errorf("marshal config: %w", err)
}
enc, err := s.deps.AEAD.Encrypt(plain, []byte("notification-channel:"+id))
if err != nil {
return nil, fmt.Errorf("encrypt config: %w", err)
}
return []byte(enc), nil
}
// decryptChannelConfig decrypts the AEAD blob and unmarshals it into dst.
func (s *Server) decryptChannelConfig(ch store.NotificationChannel, dst any) error {
plain, err := s.deps.AEAD.Decrypt(string(ch.Config), []byte("notification-channel:"+ch.ID))
if err != nil {
return fmt.Errorf("decrypt: %w", err)
}
return json.Unmarshal(plain, dst)
}
// firstNonEmpty returns the first non-empty (after TrimSpace) value in
// vals, or "". Used for fields like `name` that appear once per per-kind
// sub-form: only the visible kind's input is filled in, so PostForm.Get
// (which returns the first regardless of emptiness) would lose the
// actual value when the user edits the second or third kind.
func firstNonEmpty(vals []string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
}
// formHasValue reports whether vals contains want. Used for hidden+checkbox
// pairs (e.g. <input hidden name=x value=0> + <input checkbox name=x value=1>)
// where r.PostForm.Get returns the first ("0") even when the checkbox is
// ticked, so we have to scan the slice instead.
func formHasValue(vals []string, want string) bool {
for _, v := range vals {
if v == want {
return true
}
}
return false
}
// formFromRequest parses the common + per-kind fields from a POST form.
// The caller must have already called r.ParseForm().
func formFromRequest(r *stdhttp.Request) *notificationForm {
f := &notificationForm{
Kind: strings.TrimSpace(r.PostForm.Get("kind")),
Name: strings.TrimSpace(firstNonEmpty(r.PostForm["name"])),
Enabled: formHasValue(r.PostForm["enabled"], "1"),
DefaultPriority: strings.TrimSpace(r.PostForm.Get("default_priority")),
WebhookURL: strings.TrimSpace(r.PostForm.Get("webhook_url")),
WebhookBearerToken: r.PostForm.Get("webhook_bearer_token"),
WebhookHeaderName: strings.TrimSpace(r.PostForm.Get("webhook_header_name")),
WebhookHeaderValue: r.PostForm.Get("webhook_header_value"),
NtfyServerURL: strings.TrimSpace(r.PostForm.Get("ntfy_server_url")),
NtfyTopic: strings.TrimSpace(r.PostForm.Get("ntfy_topic")),
NtfyAccessToken: r.PostForm.Get("ntfy_access_token"),
NtfyUsername: strings.TrimSpace(r.PostForm.Get("ntfy_username")),
NtfyPassword: r.PostForm.Get("ntfy_password"),
SMTPHost: strings.TrimSpace(r.PostForm.Get("smtp_host")),
SMTPPort: strings.TrimSpace(r.PostForm.Get("smtp_port")),
SMTPEncryption: strings.TrimSpace(r.PostForm.Get("smtp_encryption")),
SMTPUsername: strings.TrimSpace(r.PostForm.Get("smtp_username")),
SMTPPassword: r.PostForm.Get("smtp_password"),
SMTPFrom: strings.TrimSpace(r.PostForm.Get("smtp_from")),
SMTPTo: strings.TrimSpace(r.PostForm.Get("smtp_to")),
}
if f.Kind == "" {
f.Kind = "webhook"
}
return f
}
// validateForm validates the common + per-kind fields. Returns a
// non-empty string on the first validation error found.
func validateForm(f *notificationForm) string {
if f.Name == "" {
return "Name is required."
}
if len(f.Name) > 100 {
return "Name must be 100 characters or fewer."
}
switch f.Kind {
case "webhook":
if f.WebhookURL == "" {
return "Webhook URL is required."
}
u, err := url.Parse(f.WebhookURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return "Webhook URL must be a valid http(s) URL."
}
case "ntfy":
if f.NtfyServerURL != "" {
u, err := url.Parse(f.NtfyServerURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") {
return "Ntfy server URL must be a valid http(s) URL."
}
}
if f.NtfyTopic == "" {
return "Ntfy topic is required."
}
case "smtp":
if f.SMTPHost == "" {
return "SMTP host is required."
}
port, err := strconv.Atoi(f.SMTPPort)
if err != nil || port < 1 || port > 65535 {
return "SMTP port must be a number between 1 and 65535."
}
switch f.SMTPEncryption {
case "starttls", "tls", "none":
default:
return "SMTP encryption must be starttls, tls, or none."
}
if f.SMTPFrom == "" {
return "SMTP From address is required."
}
if _, err := mail.ParseAddress(f.SMTPFrom); err != nil {
return "SMTP From is not a valid email address."
}
if f.SMTPTo == "" {
return "SMTP To address is required."
}
if _, err := mail.ParseAddress(f.SMTPTo); err != nil {
return "SMTP To is not a valid email address."
}
default:
return "Kind must be webhook, ntfy, or smtp."
}
return ""
}
// buildConfig constructs the per-kind notification config struct from f.
// For edit (existing != nil), blank password fields fall back to the
// stored value so the operator can save other fields without re-typing
// the credential.
func buildConfig(f *notificationForm, existing any) (any, error) {
switch f.Kind {
case "webhook":
cfg := notification.WebhookConfig{
URL: f.WebhookURL,
BearerToken: f.WebhookBearerToken,
HeaderName: f.WebhookHeaderName,
HeaderValue: f.WebhookHeaderValue,
}
if existing != nil {
ex, ok := existing.(*notification.WebhookConfig)
if ok && cfg.BearerToken == "" {
cfg.BearerToken = ex.BearerToken
}
}
return cfg, nil
case "ntfy":
cfg := notification.NtfyConfig{
ServerURL: f.NtfyServerURL,
Topic: f.NtfyTopic,
AccessToken: f.NtfyAccessToken,
Username: f.NtfyUsername,
Password: f.NtfyPassword,
}
if existing != nil {
if ex, ok := existing.(*notification.NtfyConfig); ok {
// Blank password on edit means "keep stored value"
// — same write-only treatment as smtp_password.
if cfg.AccessToken == "" {
cfg.AccessToken = ex.AccessToken
}
if cfg.Password == "" {
cfg.Password = ex.Password
}
}
}
return cfg, nil
case "smtp":
port, _ := strconv.Atoi(f.SMTPPort)
cfg := notification.SMTPConfig{
Host: f.SMTPHost,
Port: port,
Encryption: f.SMTPEncryption,
Username: f.SMTPUsername,
Password: f.SMTPPassword,
From: f.SMTPFrom,
To: f.SMTPTo,
}
if existing != nil {
ex, ok := existing.(*notification.SMTPConfig)
if ok && cfg.Password == "" {
cfg.Password = ex.Password
}
}
return cfg, nil
}
return nil, fmt.Errorf("unknown kind %q", f.Kind)
}
// ── UI handlers ───────────────────────────────────────────────────────────────
// handleUISettings renders the settings shell (defaults to the
// Notifications sub-tab in v1).
func (s *Server) handleUISettings(w stdhttp.ResponseWriter, r *stdhttp.Request) {
s.handleUINotificationsList(w, r)
}
// handleUINotificationsList renders the channel list under the
// Notifications sub-tab.
func (s *Server) handleUINotificationsList(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
page, err := s.loadSettingsPage(r)
if err != nil {
slog.Error("ui settings: load", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
s.renderSettingsPage(w, r, u, page, 0)
}
// handleUINotificationNewGet renders the kind picker + empty form.
// The ?kind= query param pre-selects the visible per-kind fields.
func (s *Server) handleUINotificationNewGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
page, err := s.loadSettingsPage(r)
if err != nil {
slog.Error("ui settings: load", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
kind := r.URL.Query().Get("kind")
if kind == "" {
kind = "webhook"
}
page.Form = &notificationForm{Kind: kind}
s.renderSettingsPage(w, r, u, page, 0)
}
// handleUINotificationNewPost validates and creates a new channel, then
// redirects to the list. Re-renders the form with an error banner on
// validation failure.
func (s *Server) handleUINotificationNewPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
f := formFromRequest(r)
if errMsg := validateForm(f); errMsg != "" {
page, _ := s.loadSettingsPage(r)
if page == nil {
page = &settingsPage{ActiveTab: "notifications"}
}
page.Form = f
page.FormError = errMsg
s.renderSettingsPage(w, r, u, page, stdhttp.StatusUnprocessableEntity)
return
}
id := ulid.Make().String()
cfg, err := buildConfig(f, nil)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
enc, err := s.encryptChannelConfig(id, cfg)
if err != nil {
slog.Error("ui notifications: encrypt", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
now := time.Now().UTC()
var dp *string
if f.DefaultPriority != "" {
dp = &f.DefaultPriority
}
ch := store.NotificationChannel{
ID: id,
Kind: f.Kind,
Name: f.Name,
Enabled: f.Enabled,
Config: enc,
DefaultPriority: dp,
CreatedAt: now,
UpdatedAt: now,
}
if err := s.deps.Store.CreateNotificationChannel(r.Context(), ch); err != nil {
slog.Error("ui notifications: create", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(),
UserID: &u.ID,
Actor: "user",
Action: "notification_channel.created",
TargetKind: ptr("notification_channel"),
TargetID: &id,
TS: now,
})
stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther)
}
// handleUINotificationEditGet fetches a channel, decrypts its config,
// and renders the edit form with values pre-filled.
func (s *Server) handleUINotificationEditGet(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
channelID := chi.URLParam(r, "id")
ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return
}
slog.Error("ui notifications: get", "id", channelID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
f := &notificationForm{
ID: ch.ID,
Kind: ch.Kind,
Name: ch.Name,
Enabled: ch.Enabled,
}
if ch.DefaultPriority != nil {
f.DefaultPriority = *ch.DefaultPriority
}
switch ch.Kind {
case "webhook":
var cfg notification.WebhookConfig
if err := s.decryptChannelConfig(*ch, &cfg); err == nil {
f.WebhookURL = cfg.URL
// BearerToken and custom headers: don't echo plaintext — shown
// via placeholder text in the template.
f.WebhookHeaderName = cfg.HeaderName
// HeaderValue and BearerToken are write-only — left blank
// so the placeholder "stored, leave blank to keep" shows.
}
case "ntfy":
var cfg notification.NtfyConfig
if err := s.decryptChannelConfig(*ch, &cfg); err == nil {
f.NtfyServerURL = cfg.ServerURL
f.NtfyTopic = cfg.Topic
f.NtfyUsername = cfg.Username
// AccessToken and Password are write-only.
}
case "smtp":
var cfg notification.SMTPConfig
if err := s.decryptChannelConfig(*ch, &cfg); err == nil {
f.SMTPHost = cfg.Host
f.SMTPPort = strconv.Itoa(cfg.Port)
f.SMTPEncryption = cfg.Encryption
f.SMTPUsername = cfg.Username
// Password is write-only — left blank.
f.SMTPFrom = cfg.From
f.SMTPTo = cfg.To
}
}
page, err := s.loadSettingsPage(r)
if err != nil {
slog.Error("ui settings: load", "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
page.Form = f
s.renderSettingsPage(w, r, u, page, 0)
}
// handleUINotificationEditPost validates the edit form, merges new
// values onto the existing config (preserving blanked-out secrets),
// re-encrypts, and updates the channel row.
func (s *Server) handleUINotificationEditPost(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
channelID := chi.URLParam(r, "id")
ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return
}
slog.Error("ui notifications: get for edit", "id", channelID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
f := formFromRequest(r)
f.ID = ch.ID
if errMsg := validateForm(f); errMsg != "" {
page, _ := s.loadSettingsPage(r)
if page == nil {
page = &settingsPage{ActiveTab: "notifications"}
}
page.Form = f
page.FormError = errMsg
s.renderSettingsPage(w, r, u, page, stdhttp.StatusUnprocessableEntity)
return
}
// Decrypt existing config so blank password fields can fall back
// to the stored values.
var existingCfg any
switch ch.Kind {
case "webhook":
var cfg notification.WebhookConfig
if derr := s.decryptChannelConfig(*ch, &cfg); derr == nil {
existingCfg = &cfg
}
case "ntfy":
var cfg notification.NtfyConfig
if derr := s.decryptChannelConfig(*ch, &cfg); derr == nil {
existingCfg = &cfg
}
case "smtp":
var cfg notification.SMTPConfig
if derr := s.decryptChannelConfig(*ch, &cfg); derr == nil {
existingCfg = &cfg
}
}
newCfg, err := buildConfig(f, existingCfg)
if err != nil {
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
enc, err := s.encryptChannelConfig(ch.ID, newCfg)
if err != nil {
slog.Error("ui notifications: re-encrypt", "id", ch.ID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
now := time.Now().UTC()
var dp *string
if f.DefaultPriority != "" {
dp = &f.DefaultPriority
}
updated := store.NotificationChannel{
ID: ch.ID,
Kind: f.Kind,
Name: f.Name,
Enabled: f.Enabled,
Config: enc,
DefaultPriority: dp,
CreatedAt: ch.CreatedAt,
UpdatedAt: now,
}
if err := s.deps.Store.UpdateNotificationChannel(r.Context(), updated); err != nil {
slog.Error("ui notifications: update", "id", ch.ID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(),
UserID: &u.ID,
Actor: "user",
Action: "notification_channel.updated",
TargetKind: ptr("notification_channel"),
TargetID: &ch.ID,
TS: now,
})
stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther)
}
// handleUINotificationDelete implements the typed-confirm pattern:
// the operator must type the channel name to proceed. On match,
// DeleteNotificationChannel + audit row + redirect. On mismatch,
// re-render with an error.
func (s *Server) handleUINotificationDelete(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
channelID := chi.URLParam(r, "id")
ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return
}
slog.Error("ui notifications: get for delete", "id", channelID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
if err := r.ParseForm(); err != nil {
stdhttp.Error(w, "bad request", stdhttp.StatusBadRequest)
return
}
confirm := strings.TrimSpace(r.PostForm.Get("confirm_name"))
if confirm != ch.Name {
page, _ := s.loadSettingsPage(r)
if page == nil {
page = &settingsPage{ActiveTab: "notifications"}
}
page.Form = &notificationForm{ID: ch.ID, Kind: ch.Kind, Name: ch.Name}
page.DeleteError = "Typed name did not match — deletion aborted."
s.renderSettingsPage(w, r, u, page, stdhttp.StatusUnprocessableEntity)
return
}
if err := s.deps.Store.DeleteNotificationChannel(r.Context(), ch.ID); err != nil {
slog.Error("ui notifications: delete", "id", ch.ID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(),
UserID: &u.ID,
Actor: "user",
Action: "notification_channel.deleted",
TargetKind: ptr("notification_channel"),
TargetID: &ch.ID,
TS: time.Now().UTC(),
})
stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther)
}
// handleUINotificationToggle flips the enabled flag for one channel
// and re-renders the row. Wired to the inline toggle in the channel
// list so operators don't need to enter the edit form just to flip a
// channel on or off. HTMX-aware: returns just the toggle fragment when
// the request carries HX-Request, otherwise redirects back to the list.
func (s *Server) handleUINotificationToggle(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
channelID := chi.URLParam(r, "id")
ch, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
stdhttp.NotFound(w, r)
return
}
slog.Error("ui notifications: get for toggle", "id", channelID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
now := time.Now().UTC()
want := !ch.Enabled
if err := s.deps.Store.SetNotificationChannelEnabled(r.Context(), ch.ID, want, now); err != nil {
slog.Error("ui notifications: set enabled", "id", ch.ID, "err", err)
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
_ = s.deps.Store.AppendAudit(r.Context(), store.AuditEntry{
ID: ulid.Make().String(),
UserID: &u.ID,
Actor: "user",
Action: "notification_channel.toggled",
TargetKind: ptr("notification_channel"),
TargetID: &ch.ID,
TS: now,
})
if r.Header.Get("HX-Request") == "true" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if want {
_, _ = w.Write([]byte(`<span class="toggle on" hx-post="/settings/notifications/` + ch.ID + `/toggle" hx-target="this" hx-swap="outerHTML" onclick="event.stopPropagation()" style="cursor:pointer"></span>`))
} else {
_, _ = w.Write([]byte(`<span class="toggle" hx-post="/settings/notifications/` + ch.ID + `/toggle" hx-target="this" hx-swap="outerHTML" onclick="event.stopPropagation()" style="cursor:pointer"></span>`))
}
return
}
stdhttp.Redirect(w, r, "/settings/notifications", stdhttp.StatusSeeOther)
}
// ── API handler ───────────────────────────────────────────────────────────────
// testResultFragment is the JSON body returned by handleAPINotificationTest.
type testResultFragment struct {
OK bool `json:"ok"`
LatencyMS int `json:"latency_ms"`
StatusCode *int `json:"status_code,omitempty"`
Error *string `json:"error,omitempty"`
}
// handleAPINotificationTest fires a single synthetic test payload
// through the named channel via Hub.DispatchOne and returns a JSON
// result. The test button in the UI posts here and renders the
// green/red pill from the response.
func (s *Server) handleAPINotificationTest(w stdhttp.ResponseWriter, r *stdhttp.Request) {
u := s.requireUIUser(w, r)
if u == nil {
return
}
if s.deps.NotificationHub == nil {
writeJSONError(w, stdhttp.StatusServiceUnavailable, "hub_not_ready",
"notification hub not initialised")
return
}
channelID := chi.URLParam(r, "id")
if _, err := s.deps.Store.GetNotificationChannel(r.Context(), channelID); err != nil {
if errors.Is(err, store.ErrNotFound) {
writeJSONError(w, stdhttp.StatusNotFound, "not_found", "channel not found")
return
}
slog.Error("api: notification test: get channel", "id", channelID, "err", err)
writeJSONError(w, stdhttp.StatusInternalServerError, "internal", "")
return
}
// AlertID is intentionally left empty for test notifications: the
// notification_log.alert_id column has a FK to alerts.id, and no
// real alert exists for a synthetic test fire. The hub leaves the
// column NULL when AlertID is empty.
payload := notification.Payload{
Event: notification.EventTest,
Severity: "info",
Kind: "test_notification",
HostName: "(test)",
Message: "Test from restic-manager — channel is working.",
RaisedAt: time.Now().UTC(),
}
entry, err := s.deps.NotificationHub.DispatchOne(r.Context(), channelID, payload)
if err != nil {
slog.Error("api: notification test: dispatch", "id", channelID, "err", err)
errStr := err.Error()
writeJSON(w, stdhttp.StatusOK, testResultFragment{
OK: false,
Error: &errStr,
})
return
}
res := testResultFragment{OK: entry.OK, StatusCode: entry.StatusCode}
if entry.LatencyMS != nil {
res.LatencyMS = *entry.LatencyMS
}
if entry.Error != nil {
res.Error = entry.Error
}
writeJSON(w, stdhttp.StatusOK, res)
}
@@ -0,0 +1,289 @@
package http
import (
"bytes"
"context"
"encoding/json"
"io"
stdhttp "net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"github.com/oklog/ulid/v2"
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
"gitea.dcglab.co.uk/steve/restic-manager/internal/notification"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
)
// newNotificationTestServer builds a test server wired with a real
// NotificationHub backed by a temporary store. It also inserts a session
// so HTTP calls are authenticated.
func newNotificationTestServer(t *testing.T) (*Server, string, *store.Store, string) {
t.Helper()
dir := t.TempDir()
st, err := store.Open(context.Background(), filepath.Join(dir, "rm.db"))
if err != nil {
t.Fatalf("store: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
keyPath := filepath.Join(dir, "secret.key")
_ = crypto.GenerateKeyFile(keyPath)
key, _ := crypto.LoadKeyFromFile(keyPath)
aead, _ := crypto.NewAEAD(key)
hub := notification.NewHub(st, aead, "http://localhost")
deps := Deps{
Cfg: config.Config{Listen: ":0", DataDir: dir, SecretKeyFile: keyPath},
Store: st,
AEAD: aead,
Hub: ws.NewHub(),
NotificationHub: hub,
BootstrapToken: "test-token",
}
s := New(deps)
ts := httptest.NewServer(s.srv.Handler)
t.Cleanup(ts.Close)
// Mint a user + session so authenticated routes work.
rawToken, _ := auth.NewToken()
userID := ulid.Make().String()
hash, _ := auth.HashPassword("test-password-long")
_ = st.CreateUser(context.Background(), store.User{
ID: userID,
Username: "testadmin",
PasswordHash: hash,
Role: store.RoleAdmin,
CreatedAt: time.Now().UTC(),
})
_ = st.CreateSession(context.Background(), store.Session{
UserID: userID,
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().Add(time.Hour).UTC(),
}, auth.HashToken(rawToken))
return s, ts.URL, st, rawToken
}
// authedClient returns a client + cookie jar that sends the test session cookie.
func authedClient(t *testing.T, rawToken string, baseURL string) *stdhttp.Client {
t.Helper()
jar := &simpleCookieJar{token: rawToken, baseURL: baseURL}
return &stdhttp.Client{Jar: jar}
}
// simpleCookieJar injects the session cookie on every request to baseURL.
type simpleCookieJar struct {
token string
baseURL string
}
func (j *simpleCookieJar) SetCookies(_ *url.URL, _ []*stdhttp.Cookie) {}
func (j *simpleCookieJar) Cookies(u *url.URL) []*stdhttp.Cookie {
if !strings.HasPrefix(u.String(), j.baseURL) {
return nil
}
return []*stdhttp.Cookie{{Name: sessionCookieName, Value: j.token}}
}
// createTestWebhookChannel inserts a webhook channel into the store
// for the given server's AEAD, targeting sink.
func createTestWebhookChannel(t *testing.T, s *Server, st *store.Store, sink string) string {
t.Helper()
id := "ch-test-" + strings.ReplaceAll(t.Name(), "/", "-")
cfg, _ := json.Marshal(notification.WebhookConfig{URL: sink})
enc, err := s.deps.AEAD.Encrypt(cfg, []byte("notification-channel:"+id))
if err != nil {
t.Fatalf("encrypt: %v", err)
}
err = st.CreateNotificationChannel(context.Background(), store.NotificationChannel{
ID: id,
Kind: "webhook",
Name: "test-webhook",
Enabled: true,
Config: []byte(enc),
CreatedAt: time.Now().UTC(),
UpdatedAt: time.Now().UTC(),
})
if err != nil {
t.Fatalf("create channel: %v", err)
}
return id
}
// TestAPINotificationTestEndToEnd is the primary plan test:
// configure a webhook channel pointing at an httptest sink, POST the
// test endpoint, assert the synthetic event landed at the sink and a
// notification_log row with event="alert.test" ok=1 was persisted.
func TestAPINotificationTestEndToEnd(t *testing.T) {
t.Parallel()
// Sink — records incoming request bodies.
var received [][]byte
sink := httptest.NewServer(stdhttp.HandlerFunc(func(w stdhttp.ResponseWriter, r *stdhttp.Request) {
body, _ := io.ReadAll(r.Body)
received = append(received, body)
w.WriteHeader(stdhttp.StatusOK)
}))
defer sink.Close()
s, baseURL, st, rawToken := newNotificationTestServer(t)
channelID := createTestWebhookChannel(t, s, st, sink.URL)
client := authedClient(t, rawToken, baseURL)
res, err := client.Post(baseURL+"/api/notifications/"+channelID+"/test",
"application/json", bytes.NewReader(nil))
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != stdhttp.StatusOK {
body, _ := io.ReadAll(res.Body)
t.Fatalf("status %d: %s", res.StatusCode, body)
}
var result testResultFragment
if err := json.NewDecoder(res.Body).Decode(&result); err != nil {
t.Fatalf("decode: %v", err)
}
if !result.OK {
errStr := "<nil>"
if result.Error != nil {
errStr = *result.Error
}
t.Fatalf("expected ok=true, got false; error=%s", errStr)
}
// The sink should have received exactly one request.
if len(received) != 1 {
t.Fatalf("sink: expected 1 request, got %d", len(received))
}
// Decode the webhook body and check the event field.
var body map[string]any
if err := json.Unmarshal(received[0], &body); err != nil {
t.Fatalf("decode sink body: %v", err)
}
if body["event"] != string(notification.EventTest) {
t.Errorf("event: got %v, want %s", body["event"], notification.EventTest)
}
// notification_log should have one row with event=alert.test and ok=1.
var n int
if err := st.DB().QueryRow(
`SELECT COUNT(*) FROM notification_log
WHERE channel_id = ? AND event = 'alert.test' AND ok = 1`,
channelID,
).Scan(&n); err != nil {
t.Fatalf("query log: %v", err)
}
if n != 1 {
t.Fatalf("notification_log: expected 1 row, got %d", n)
}
}
// TestAPINotificationTestNotFound confirms a 404 for an unknown channel.
func TestAPINotificationTestNotFound(t *testing.T) {
t.Parallel()
_, baseURL, _, rawToken := newNotificationTestServer(t)
client := authedClient(t, rawToken, baseURL)
res, err := client.Post(baseURL+"/api/notifications/no-such-channel/test",
"application/json", bytes.NewReader(nil))
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = res.Body.Close() }()
if res.StatusCode != stdhttp.StatusNotFound {
t.Errorf("expected 404, got %d", res.StatusCode)
}
}
// TestAPINotificationTestUnauthed confirms a redirect (or 4xx) when
// there is no session cookie.
func TestAPINotificationTestUnauthed(t *testing.T) {
t.Parallel()
_, baseURL, _, _ := newNotificationTestServer(t)
// Use a client that does NOT follow redirects and has no cookie.
client := &stdhttp.Client{
CheckRedirect: func(_ *stdhttp.Request, _ []*stdhttp.Request) error {
return stdhttp.ErrUseLastResponse
},
}
res, err := client.Post(baseURL+"/api/notifications/any-id/test",
"application/json", bytes.NewReader(nil))
if err != nil {
t.Fatalf("post: %v", err)
}
defer func() { _ = res.Body.Close() }()
// requireUIUser redirects to /login for unauthenticated requests.
if res.StatusCode != stdhttp.StatusSeeOther && res.StatusCode != stdhttp.StatusUnauthorized {
t.Errorf("expected 303 or 401, got %d", res.StatusCode)
}
}
// TestNotificationCreateAndDelete is a CRUD round-trip exercising
// the store methods. The handler layer would return template errors
// (no templates in tests), so we exercise just the store-level API
// that the handlers call, confirming the plumbing compiles and works.
func TestNotificationCreateAndDelete(t *testing.T) {
t.Parallel()
s, _, st, _ := newNotificationTestServer(t)
id := "ch-crud-test"
cfg, _ := json.Marshal(notification.WebhookConfig{URL: "https://example.com/hook"})
enc, _ := s.deps.AEAD.Encrypt(cfg, []byte("notification-channel:"+id))
now := time.Now().UTC()
err := st.CreateNotificationChannel(context.Background(), store.NotificationChannel{
ID: id,
Kind: "webhook",
Name: "crud-test",
Enabled: true,
Config: []byte(enc),
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
t.Fatalf("create: %v", err)
}
// Read it back and decrypt.
ch, err := st.GetNotificationChannel(context.Background(), id)
if err != nil {
t.Fatalf("get: %v", err)
}
var got notification.WebhookConfig
plain, err := s.deps.AEAD.Decrypt(string(ch.Config), []byte("notification-channel:"+id))
if err != nil {
t.Fatalf("decrypt: %v", err)
}
if err := json.Unmarshal(plain, &got); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if got.URL != "https://example.com/hook" {
t.Errorf("URL: got %q, want %q", got.URL, "https://example.com/hook")
}
// Delete.
if err := st.DeleteNotificationChannel(context.Background(), id); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := st.GetNotificationChannel(context.Background(), id); err == nil {
t.Error("expected ErrNotFound after delete")
}
}
+2 -2
View File
@@ -244,7 +244,7 @@ func (s *Server) handleUIHostRepo(w stdhttp.ResponseWriter, r *stdhttp.Request)
return
}
page.SavedSection = r.URL.Query().Get("saved")
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = host.Name + " repo · restic-manager"
view.Page = *page
if err := s.deps.UI.Render(w, "host_repo", view); err != nil {
@@ -268,7 +268,7 @@ func (s *Server) renderRepoPage(w stdhttp.ResponseWriter, r *stdhttp.Request, u
page.AdminCredsError = adminErr
page.BandwidthError = bwErr
page.MaintenanceError = mntErr
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = host.Name + " repo · restic-manager"
view.Page = *page
w.WriteHeader(stdhttp.StatusUnprocessableEntity)
+6 -6
View File
@@ -105,7 +105,7 @@ func (s *Server) handleUIRestoreGet(w stdhttp.ResponseWriter, r *stdhttp.Request
}
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = "Restore · " + host.Name
view.Page = page
if err := s.deps.UI.Render(w, "host_restore", view); err != nil {
@@ -161,7 +161,7 @@ func (s *Server) handleUIRestorePost(w stdhttp.ResponseWriter, r *stdhttp.Reques
break
}
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = "Restore · " + host.Name
view.Page = page
w.WriteHeader(status)
@@ -329,7 +329,7 @@ func (s *Server) handleUIRestoreTree(w stdhttp.ResponseWriter, r *stdhttp.Reques
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
Error: "agent offline",
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Page = page
_ = s.deps.UI.RenderPartial(w, "tree_node", view)
return
@@ -345,7 +345,7 @@ func (s *Server) handleUIRestoreTree(w stdhttp.ResponseWriter, r *stdhttp.Reques
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
Error: err.Error(),
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Page = page
_ = s.deps.UI.RenderPartial(w, "tree_node", view)
return
@@ -355,7 +355,7 @@ func (s *Server) handleUIRestoreTree(w stdhttp.ResponseWriter, r *stdhttp.Reques
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
Error: result.Error,
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Page = page
_ = s.deps.UI.RenderPartial(w, "tree_node", view)
return
@@ -382,7 +382,7 @@ func (s *Server) handleUIRestoreTree(w stdhttp.ResponseWriter, r *stdhttp.Reques
HostID: host.ID, SnapshotID: snapshotID, Path: pathArg,
Children: children,
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Page = page
if err := s.deps.UI.RenderPartial(w, "tree_node", view); err != nil {
slog.Warn("ui restore tree: render partial", "err", err)
+4 -4
View File
@@ -112,7 +112,7 @@ func (s *Server) handleUISchedulesList(w stdhttp.ResponseWriter, r *stdhttp.Requ
chrome.ScheduleCount = len(scheds)
chrome.SourceGroupCount = len(groups)
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = host.Name + " schedules · restic-manager"
view.Page = hostSchedulesPage{
hostChromeData: chrome,
@@ -140,7 +140,7 @@ func (s *Server) handleUIScheduleNewGet(w stdhttp.ResponseWriter, r *stdhttp.Req
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = "New schedule · " + host.Name + " · restic-manager"
view.Page = scheduleEditPage{
hostChromeData: s.loadHostChrome(r, *host, "schedules", "new schedule"),
@@ -186,7 +186,7 @@ func (s *Server) handleUIScheduleEditGet(w stdhttp.ResponseWriter, r *stdhttp.Re
for _, gid := range sc.SourceGroupIDs {
selected[gid] = true
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = "Edit schedule · " + host.Name + " · restic-manager"
view.Page = scheduleEditPage{
hostChromeData: s.loadHostChrome(r, *host, "schedules", "edit schedule"),
@@ -415,7 +415,7 @@ func (s *Server) renderScheduleFormError(w stdhttp.ResponseWriter, r *stdhttp.Re
saveAction = "/hosts/" + host.ID + "/schedules/" + sid + "/edit"
crumb = "edit schedule"
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = "Schedule · " + host.Name + " · restic-manager"
view.Page = scheduleEditPage{
hostChromeData: s.loadHostChrome(r, *host, "schedules", crumb),
+4 -4
View File
@@ -121,7 +121,7 @@ func (s *Server) handleUIHostSources(w stdhttp.ResponseWriter, r *stdhttp.Reques
// loadHostChrome already counted groups; reuse count we just got.
chrome.SourceGroupCount = len(groups)
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = host.Name + " sources · restic-manager"
view.Page = hostSourcesPage{hostChromeData: chrome, Groups: rows}
if err := s.deps.UI.Render(w, "host_sources", view); err != nil {
@@ -139,7 +139,7 @@ func (s *Server) handleUISourceGroupNewGet(w stdhttp.ResponseWriter, r *stdhttp.
if !ok {
return
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = "New source group · " + host.Name + " · restic-manager"
view.Page = sourceGroupEditPage{
hostChromeData: s.loadHostChrome(r, *host, "sources", "new source group"),
@@ -173,7 +173,7 @@ func (s *Server) handleUISourceGroupEditGet(w stdhttp.ResponseWriter, r *stdhttp
stdhttp.Error(w, "internal", stdhttp.StatusInternalServerError)
return
}
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = g.Name + " · " + host.Name + " · restic-manager"
form := formFromGroup(*g)
form.PreHook = s.decryptHookOrFallback(g.PreHook, "", host.ID, "pre")
@@ -362,7 +362,7 @@ func (s *Server) handleUISourceGroupDelete(w stdhttp.ResponseWriter, r *stdhttp.
// typed input intact + an error banner. Returns 422 to signal "form
// rejected" while still returning HTML (mirrors handleUIAddHostPost).
func (s *Server) renderSourceFormError(w stdhttp.ResponseWriter, r *stdhttp.Request, u *ui.User, host *store.Host, gid string, isNew bool, form sourceFormData, msg string) {
view := s.baseView(u)
view := s.baseView(r, u)
view.Title = "Source group · " + host.Name + " · restic-manager"
saveAction := "/hosts/" + host.ID + "/sources/new"
crumb := "new source group"
+62
View File
@@ -38,6 +38,68 @@ func funcMap() template.FuncMap {
// list packs strings into a slice — handy for inline ranges
// in templates (e.g. quick-pick cron presets).
"list": func(items ...string) []string { return items },
// dict builds a map[string]any from alternating key-value pairs.
// Useful for passing multiple named values to a sub-template:
// {{template "foo" (dict "A" $a "B" $b)}}
"dict": func(pairs ...any) map[string]any {
m := make(map[string]any, len(pairs)/2)
for i := 0; i+1 < len(pairs); i += 2 {
if k, ok := pairs[i].(string); ok {
m[k] = pairs[i+1]
}
}
return m
},
// mapGet retrieves a string value from a map[string]string by key.
// Returns "" when the key is absent or the map is nil. Used by the
// alert_row partial to resolve host_id → host name.
"mapGet": func(m map[string]string, key *string) string {
if m == nil || key == nil {
return ""
}
return m[*key]
},
// alertStatus derives the display status of an alert from its DB
// fields: "open", "acknowledged", or "resolved".
// Accepts any value — returns "" for unrecognised input so templates
// can still render safely.
"alertStatus": func(resolvedAt, acknowledgedAt any) string {
isSet := func(v any) bool {
if v == nil {
return false
}
switch t := v.(type) {
case *time.Time:
return t != nil
}
return false
}
if isSet(resolvedAt) {
return "resolved"
}
if isSet(acknowledgedAt) {
return "acknowledged"
}
return "open"
},
// stillHappening returns true when last_seen_at is within the last
// 60 seconds — used to render the "still happening · Ns ago" pill
// on alert rows where the signal is still firing.
"stillHappening": func(v any) bool {
var t time.Time
switch x := v.(type) {
case time.Time:
t = x
case *time.Time:
if x == nil {
return false
}
t = *x
default:
return false
}
return time.Since(t) < 60*time.Second
},
}
}
+2
View File
@@ -93,6 +93,8 @@ func New() (*Renderer, error) {
"templates/partials/awaiting_agent.html",
"templates/partials/host_chrome.html",
"templates/partials/tree_node.html",
"templates/partials/alert_row.html",
"templates/partials/crit_banner.html",
}
pageEntries, err := fs.Glob(web.FS, "templates/pages/*.html")
+12
View File
@@ -0,0 +1,12 @@
package ui
import "testing"
// TestNewParsesAllTemplates ensures ui.New() can parse every template
// registered under templates/pages/ without error. Run this after
// adding or editing any template file.
func TestNewParsesAllTemplates(t *testing.T) {
if _, err := New(); err != nil {
t.Fatalf("ui.New() returned error: %v", err)
}
}
+18
View File
@@ -12,6 +12,7 @@ import (
"github.com/coder/websocket"
"gitea.dcglab.co.uk/steve/restic-manager/internal/alert"
"gitea.dcglab.co.uk/steve/restic-manager/internal/api"
"gitea.dcglab.co.uk/steve/restic-manager/internal/auth"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
@@ -22,6 +23,9 @@ type HandlerDeps struct {
Hub *Hub
Store *store.Store
JobHub *JobHub
// AlertEngine receives job-finished and host-online events so the
// alert engine can evaluate its rules. Optional; nil = no-op.
AlertEngine *alert.Engine
// OnHello is called once per successful hello, after the host row
// has been touched and the conn registered. Used by the HTTP
// layer to push host_credentials down as a config.update before
@@ -140,6 +144,9 @@ func runAgentLoop(ctx context.Context, c *Conn, hostID string, deps HandlerDeps)
helloPayload.ProtocolVersion, now); err != nil {
slog.Error("ws mark host hello failed", "host_id", hostID, "err", err)
}
if deps.AlertEngine != nil {
deps.AlertEngine.NotifyHostOnline(hostID)
}
deps.Hub.Register(hostID, c)
defer deps.Hub.Unregister(hostID, c)
@@ -210,6 +217,17 @@ func dispatchAgentMessage(ctx context.Context, c *Conn, hostID string, env api.E
if deps.JobHub != nil {
deps.JobHub.Broadcast(p.JobID, env)
}
if deps.AlertEngine != nil {
if job, err := deps.Store.GetJob(ctx, p.JobID); err == nil && job != nil {
deps.AlertEngine.NotifyJobFinished(alert.JobFinishedEvent{
HostID: hostID,
JobID: p.JobID,
Kind: job.Kind,
Status: string(p.Status),
When: p.FinishedAt,
})
}
}
case api.MsgLogStream:
var p api.LogStreamLine
+245
View File
@@ -0,0 +1,245 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
"github.com/oklog/ulid/v2"
)
// AlertFilter narrows ListAlerts.
type AlertFilter struct {
Status string // "open" | "acknowledged" | "resolved" | "all" | ""
Severity string // "info" | "warning" | "critical" | ""
HostID string // empty = any host
Search string // substring match on message
Limit int // 0 = no limit
}
// RaiseOrTouch implements the dedup + last_seen_at bump pattern. If
// an alert with (host_id, kind, resolved_at IS NULL) already exists,
// it touches last_seen_at + message and returns (id, false). Otherwise
// inserts a fresh row and returns (id, true). Caller fires a
// notification only when didRaise=true.
func (s *Store) RaiseOrTouch(ctx context.Context, hostID, kind, severity, message string, when time.Time) (id string, didRaise bool, err error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return "", false, fmt.Errorf("store: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
row := tx.QueryRowContext(ctx,
`SELECT id FROM alerts WHERE host_id = ? AND kind = ? AND resolved_at IS NULL LIMIT 1`,
hostID, kind)
var existing string
switch err := row.Scan(&existing); {
case err == nil:
_, uerr := tx.ExecContext(ctx,
`UPDATE alerts SET last_seen_at = ?, message = ? WHERE id = ?`,
when.UTC().Format(time.RFC3339Nano), message, existing)
if uerr != nil {
return "", false, fmt.Errorf("store: touch alert: %w", uerr)
}
if err := tx.Commit(); err != nil {
return "", false, err
}
return existing, false, nil
case errors.Is(err, sql.ErrNoRows):
// fall through to insert
default:
return "", false, fmt.Errorf("store: lookup alert: %w", err)
}
id = ulid.Make().String()
whenStr := when.UTC().Format(time.RFC3339Nano)
_, err = tx.ExecContext(ctx,
`INSERT INTO alerts (id, host_id, kind, severity, message, created_at, last_seen_at)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
id, hostID, kind, severity, message, whenStr, whenStr)
if err != nil {
return "", false, fmt.Errorf("store: insert alert: %w", err)
}
if err := tx.Commit(); err != nil {
return "", false, err
}
_ = s.refreshHostOpenAlertCount(ctx, s.db, hostID)
return id, true, nil
}
// refreshHostOpenAlertCount recomputes hosts.open_alert_count from the
// alerts table for one host. Self-healing: idempotent and survives
// out-of-order edits. Best-effort — errors are returned but callers
// generally discard them since the projection is non-critical.
func (s *Store) refreshHostOpenAlertCount(ctx context.Context, exec interface {
ExecContext(context.Context, string, ...any) (sql.Result, error)
}, hostID string,
) error {
if hostID == "" {
return nil
}
_, err := exec.ExecContext(ctx,
`UPDATE hosts SET open_alert_count = (
SELECT COUNT(*) FROM alerts
WHERE host_id = ? AND resolved_at IS NULL
) WHERE id = ?`, hostID, hostID)
if err != nil {
return fmt.Errorf("store: refresh open_alert_count: %w", err)
}
return nil
}
// Acknowledge sets acknowledged_at + acknowledged_by; does NOT set
// resolved_at. Idempotent — re-acknowledging just refreshes the timestamp.
func (s *Store) Acknowledge(ctx context.Context, id, userID string, when time.Time) error {
res, err := s.db.ExecContext(ctx,
`UPDATE alerts SET acknowledged_at = ?, acknowledged_by = ?
WHERE id = ? AND resolved_at IS NULL`,
when.UTC().Format(time.RFC3339Nano), userID, id)
if err != nil {
return fmt.Errorf("store: ack alert: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotFound
}
return nil
}
// Resolve marks the alert resolved. Idempotent on already-resolved rows
// (no-op).
func (s *Store) Resolve(ctx context.Context, id string, when time.Time) error {
var hostID sql.NullString
_ = s.db.QueryRowContext(ctx, `SELECT host_id FROM alerts WHERE id = ?`, id).Scan(&hostID)
_, err := s.db.ExecContext(ctx,
`UPDATE alerts SET resolved_at = ?
WHERE id = ? AND resolved_at IS NULL`,
when.UTC().Format(time.RFC3339Nano), id)
if err != nil {
return fmt.Errorf("store: resolve alert: %w", err)
}
if hostID.Valid {
_ = s.refreshHostOpenAlertCount(ctx, s.db, hostID.String)
}
return nil
}
// AutoResolve closes every open alert for the (host_id, kind) pair.
// Used by the engine when a rule's underlying condition clears (e.g.
// next backup succeeded so backup_failed clears).
func (s *Store) AutoResolve(ctx context.Context, hostID, kind string, when time.Time) error {
_, err := s.db.ExecContext(ctx,
`UPDATE alerts SET resolved_at = ?
WHERE host_id = ? AND kind = ? AND resolved_at IS NULL`,
when.UTC().Format(time.RFC3339Nano), hostID, kind)
if err != nil {
return fmt.Errorf("store: auto-resolve: %w", err)
}
_ = s.refreshHostOpenAlertCount(ctx, s.db, hostID)
return nil
}
// GetAlert reads one row.
func (s *Store) GetAlert(ctx context.Context, id string) (*Alert, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, host_id, kind, severity, message, created_at, last_seen_at,
acknowledged_at, acknowledged_by, resolved_at
FROM alerts WHERE id = ?`, id)
return scanAlert(row.Scan)
}
// ListAlerts is the filtered list. Sort: open-first, then by created_at desc.
func (s *Store) ListAlerts(ctx context.Context, f AlertFilter) ([]Alert, error) {
q := `SELECT id, host_id, kind, severity, message, created_at, last_seen_at,
acknowledged_at, acknowledged_by, resolved_at FROM alerts`
conds := []string{}
args := []any{}
switch f.Status {
case "open":
conds = append(conds, "resolved_at IS NULL AND acknowledged_at IS NULL")
case "acknowledged":
conds = append(conds, "resolved_at IS NULL AND acknowledged_at IS NOT NULL")
case "resolved":
conds = append(conds, "resolved_at IS NOT NULL")
case "all", "":
// no-op
}
if f.Severity != "" {
conds = append(conds, "severity = ?")
args = append(args, f.Severity)
}
if f.HostID != "" {
conds = append(conds, "host_id = ?")
args = append(args, f.HostID)
}
if f.Search != "" {
conds = append(conds, "message LIKE ?")
args = append(args, "%"+f.Search+"%")
}
if len(conds) > 0 {
q += " WHERE " + strings.Join(conds, " AND ")
}
q += ` ORDER BY (resolved_at IS NULL) DESC, created_at DESC`
if f.Limit > 0 {
q += ` LIMIT ?`
args = append(args, f.Limit)
}
rows, err := s.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, fmt.Errorf("store: list alerts: %w", err)
}
defer func() { _ = rows.Close() }()
var out []Alert
for rows.Next() {
a, err := scanAlert(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *a)
}
return out, rows.Err()
}
// scanAlert centralises the column read so the GetAlert and
// ListAlerts paths agree on column order. Pass row.Scan or rows.Scan.
func scanAlert(scan func(...any) error) (*Alert, error) {
var a Alert
var hostID, lastSeen, ackedAt, ackedBy, resolvedAt sql.NullString
var createdAt string
if err := scan(&a.ID, &hostID, &a.Kind, &a.Severity, &a.Message,
&createdAt, &lastSeen, &ackedAt, &ackedBy, &resolvedAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("store: scan alert: %w", err)
}
if hostID.Valid {
v := hostID.String
a.HostID = &v
}
t, err := time.Parse(time.RFC3339Nano, createdAt)
if err != nil {
return nil, fmt.Errorf("store: parse created_at: %w", err)
}
a.CreatedAt = t
if lastSeen.Valid {
t, _ := time.Parse(time.RFC3339Nano, lastSeen.String)
a.LastSeenAt = &t
}
if ackedAt.Valid {
t, _ := time.Parse(time.RFC3339Nano, ackedAt.String)
a.AcknowledgedAt = &t
}
if ackedBy.Valid {
v := ackedBy.String
a.AcknowledgedBy = &v
}
if resolvedAt.Valid {
t, _ := time.Parse(time.RFC3339Nano, resolvedAt.String)
a.ResolvedAt = &t
}
return &a, nil
}
+179
View File
@@ -0,0 +1,179 @@
package store
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/oklog/ulid/v2"
)
func newTestStoreWithHost(t *testing.T) (*Store, string) {
t.Helper()
dir := t.TempDir()
st, err := Open(context.Background(), filepath.Join(dir, "rm.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
t.Cleanup(func() { _ = st.Close() })
hostID := ulid.Make().String()
if err := st.CreateHost(context.Background(), Host{
ID: hostID, Name: "h", OS: "linux", Arch: "amd64",
EnrolledAt: time.Now().UTC(),
}, "deadbeef", ""); err != nil {
t.Fatalf("create host: %v", err)
}
return st, hostID
}
func TestRaiseOrTouchInsertsThenTouches(t *testing.T) {
t.Parallel()
st, hostID := newTestStoreWithHost(t)
ctx := context.Background()
t0 := time.Now().UTC()
id1, didRaise, err := st.RaiseOrTouch(ctx, hostID, "backup_failed", "warning",
"Backup failed: 401", t0)
if err != nil {
t.Fatalf("first raise: %v", err)
}
if !didRaise {
t.Fatalf("first call must didRaise=true")
}
if id1 == "" {
t.Fatalf("expected non-empty id")
}
// Second call within the same open window should touch, not insert.
t1 := t0.Add(60 * time.Second)
id2, didRaise2, err := st.RaiseOrTouch(ctx, hostID, "backup_failed", "warning",
"Backup failed: 401 (still)", t1)
if err != nil {
t.Fatalf("touch: %v", err)
}
if didRaise2 {
t.Fatalf("second call must didRaise=false")
}
if id2 != id1 {
t.Fatalf("touch returned a different id: got %q want %q", id2, id1)
}
// last_seen_at and message must be updated.
got, err := st.GetAlert(ctx, id1)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.LastSeenAt == nil || !got.LastSeenAt.Equal(t1) {
t.Errorf("last_seen_at: got %v want %v", got.LastSeenAt, t1)
}
if got.Message != "Backup failed: 401 (still)" {
t.Errorf("message not refreshed: %q", got.Message)
}
}
func TestResolveAndReRaiseStartsFreshAlert(t *testing.T) {
t.Parallel()
st, hostID := newTestStoreWithHost(t)
ctx := context.Background()
t0 := time.Now().UTC()
id1, _, err := st.RaiseOrTouch(ctx, hostID, "backup_failed", "warning", "first", t0)
if err != nil {
t.Fatalf("raise: %v", err)
}
if err := st.Resolve(ctx, id1, t0.Add(time.Minute)); err != nil {
t.Fatalf("resolve: %v", err)
}
id2, didRaise, err := st.RaiseOrTouch(ctx, hostID, "backup_failed", "warning", "second", t0.Add(2*time.Minute))
if err != nil {
t.Fatalf("re-raise: %v", err)
}
if !didRaise {
t.Fatalf("post-resolve raise must didRaise=true")
}
if id2 == id1 {
t.Fatalf("re-raise reused the resolved id; want a fresh row")
}
}
func TestAcknowledgeKeepsAlertOpen(t *testing.T) {
t.Parallel()
st, hostID := newTestStoreWithHost(t)
ctx := context.Background()
// Create a real user so the acknowledged_by FK is satisfied.
userID := ulid.Make().String()
if err := st.CreateUser(ctx, User{
ID: userID, Username: "ackuser", PasswordHash: "x",
Role: RoleOperator, CreatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("create user: %v", err)
}
id, _, err := st.RaiseOrTouch(ctx, hostID, "backup_failed", "warning", "m", time.Now().UTC())
if err != nil {
t.Fatalf("raise: %v", err)
}
if err := st.Acknowledge(ctx, id, userID, time.Now().UTC()); err != nil {
t.Fatalf("ack: %v", err)
}
got, err := st.GetAlert(ctx, id)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.AcknowledgedAt == nil {
t.Errorf("acknowledged_at not set")
}
if got.AcknowledgedBy == nil || *got.AcknowledgedBy != userID {
t.Errorf("acknowledged_by: got %v want %q", got.AcknowledgedBy, userID)
}
if got.ResolvedAt != nil {
t.Errorf("ack must not set resolved_at; got %v", got.ResolvedAt)
}
}
func TestAutoResolveClearsOpenAlerts(t *testing.T) {
t.Parallel()
st, hostID := newTestStoreWithHost(t)
ctx := context.Background()
t0 := time.Now().UTC()
id, _, _ := st.RaiseOrTouch(ctx, hostID, "backup_failed", "warning", "m", t0)
if err := st.AutoResolve(ctx, hostID, "backup_failed", t0.Add(time.Minute)); err != nil {
t.Fatalf("auto-resolve: %v", err)
}
got, _ := st.GetAlert(ctx, id)
if got.ResolvedAt == nil {
t.Errorf("expected resolved_at set")
}
}
func TestListAlertsFilters(t *testing.T) {
t.Parallel()
st, hostID := newTestStoreWithHost(t)
ctx := context.Background()
t0 := time.Now().UTC()
// One open warning + one resolved info.
_, _, _ = st.RaiseOrTouch(ctx, hostID, "backup_failed", "warning", "open", t0)
id2, _, _ := st.RaiseOrTouch(ctx, hostID, "stale_schedule", "info", "done", t0)
_ = st.Resolve(ctx, id2, t0.Add(time.Minute))
open, err := st.ListAlerts(ctx, AlertFilter{Status: "open"})
if err != nil {
t.Fatalf("list open: %v", err)
}
if len(open) != 1 || open[0].Severity != "warning" {
t.Errorf("open filter: got %+v", open)
}
all, err := st.ListAlerts(ctx, AlertFilter{Status: "all"})
if err != nil {
t.Fatalf("list all: %v", err)
}
if len(all) != 2 {
t.Errorf("all filter: got %d, want 2", len(all))
}
}
+49
View File
@@ -110,6 +110,55 @@ func (s *Store) MarkHostsOfflineStale(ctx context.Context, cutoff time.Time) (in
return n, nil
}
// MarkHostsOfflineStaleReturnIDs flips any host that hasn't been seen
// since before `cutoff` from 'online' to 'offline' and returns the IDs
// of every host that was flipped. Uses a single transaction.
func (s *Store) MarkHostsOfflineStaleReturnIDs(ctx context.Context, cutoff time.Time) ([]string, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("store: begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
cutoffStr := cutoff.UTC().Format(time.RFC3339Nano)
rows, err := tx.QueryContext(ctx,
`SELECT id FROM hosts
WHERE status = 'online'
AND (last_seen_at IS NULL OR last_seen_at < ?)`,
cutoffStr)
if err != nil {
return nil, fmt.Errorf("store: select stale hosts: %w", err)
}
var ids []string
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
_ = rows.Close()
return nil, fmt.Errorf("store: scan stale host id: %w", err)
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("store: iterate stale hosts: %w", err)
}
_ = rows.Close()
if len(ids) > 0 {
if _, err := tx.ExecContext(ctx,
`UPDATE hosts SET status = 'offline'
WHERE status = 'online'
AND (last_seen_at IS NULL OR last_seen_at < ?)`,
cutoffStr); err != nil {
return nil, fmt.Errorf("store: mark offline: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("store: commit: %w", err)
}
return ids, nil
}
// ListHosts returns every host. Phase 1 callers fit a small fleet in
// memory; pagination lands when it matters.
func (s *Store) ListHosts(ctx context.Context) ([]Host, error) {
+71
View File
@@ -0,0 +1,71 @@
package store
import (
"context"
"path/filepath"
"testing"
)
func TestMigration0013AlertsLastSeen(t *testing.T) {
t.Parallel()
dir := t.TempDir()
st, err := Open(context.Background(), filepath.Join(dir, "rm.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
// Column must exist after migration. Best signal: PRAGMA table_info.
rows, err := st.DB().Query(`SELECT name FROM pragma_table_info('alerts')`)
if err != nil {
t.Fatalf("pragma: %v", err)
}
defer rows.Close()
cols := map[string]bool{}
for rows.Next() {
var n string
if err := rows.Scan(&n); err != nil {
t.Fatalf("scan: %v", err)
}
cols[n] = true
}
if err := rows.Err(); err != nil {
t.Fatalf("rows iter: %v", err)
}
if !cols["last_seen_at"] {
t.Fatalf("alerts.last_seen_at not present after migration; cols=%v", cols)
}
}
func TestMigration0014NotificationsTables(t *testing.T) {
t.Parallel()
dir := t.TempDir()
st, err := Open(context.Background(), filepath.Join(dir, "rm.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
for _, want := range []string{"notification_channels", "notification_log"} {
var n int
if err := st.DB().QueryRow(
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, want,
).Scan(&n); err != nil {
t.Fatalf("scan: %v", err)
}
if n != 1 {
t.Errorf("table %q missing after migration", want)
}
}
// Sanity: kind CHECK accepts all three v1 kinds.
for _, k := range []string{"webhook", "ntfy", "smtp"} {
_, err := st.DB().Exec(
`INSERT INTO notification_channels (id, kind, name, config, created_at, updated_at)
VALUES (?, ?, ?, x'00', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z')`,
"test-"+k, k, "test-"+k)
if err != nil {
t.Errorf("insert %q rejected by CHECK: %v", k, err)
}
}
}
@@ -0,0 +1,16 @@
-- 0013_alerts_last_seen.sql
--
-- Add alerts.last_seen_at to support open-alert dedup with
-- recurrence-tracking. The engine bumps this column on every tick
-- where a rule still matches an existing open alert, so the UI can
-- render "still happening · Ns ago" without sending a fresh
-- notification.
--
-- Column-level ALTER per CLAUDE.md (no rebuild — alerts has inbound
-- FK from acknowledged_by → users; rebuild would risk cascade).
-- Backfill last_seen_at = created_at for any pre-existing rows so
-- the column is non-null in practice (stays nullable in the schema
-- for forwards-compat with rows that haven't been touched yet).
ALTER TABLE alerts ADD COLUMN last_seen_at TEXT;
UPDATE alerts SET last_seen_at = created_at WHERE last_seen_at IS NULL;
@@ -0,0 +1,42 @@
-- 0014_notifications.sql
--
-- Notification channels (operator-configured destinations: webhook,
-- ntfy, SMTP) and the dispatch log. Both are net-new — no rebuild
-- pattern needed.
--
-- config is an AEAD-encrypted JSON blob. Per-kind shape lives in
-- internal/notification/{webhook,ntfy,smtp}.go. The CHECK keeps wire
-- consistency — adding a new kind requires a follow-up migration
-- (forces the implementer to think about it).
CREATE TABLE notification_channels (
id TEXT PRIMARY KEY,
kind TEXT NOT NULL CHECK (kind IN ('webhook', 'ntfy', 'smtp')),
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1 CHECK (enabled IN (0, 1)),
config BLOB NOT NULL, -- AEAD-encrypted JSON; per-kind shape
default_priority TEXT, -- ntfy only; null for webhook + smtp
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_fired_at TEXT
);
CREATE INDEX notification_channels_enabled
ON notification_channels(enabled) WHERE enabled = 1;
CREATE TABLE notification_log (
id TEXT PRIMARY KEY,
channel_id TEXT NOT NULL REFERENCES notification_channels(id) ON DELETE CASCADE,
alert_id TEXT REFERENCES alerts(id) ON DELETE SET NULL,
event TEXT NOT NULL, -- alert.raised | alert.acknowledged | alert.resolved | alert.test
ok INTEGER NOT NULL CHECK (ok IN (0, 1)),
status_code INTEGER,
latency_ms INTEGER,
error TEXT,
fired_at TEXT NOT NULL
);
CREATE INDEX notification_log_channel
ON notification_log(channel_id, fired_at DESC);
CREATE INDEX notification_log_alert
ON notification_log(alert_id);
+224
View File
@@ -0,0 +1,224 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
// NotificationChannel mirrors a row in notification_channels. The
// Config field is the AEAD-encrypted JSON blob; callers (in the
// notification package) decrypt before use.
type NotificationChannel struct {
ID string
Kind string // "webhook" | "ntfy" | "smtp"
Name string
Enabled bool
Config []byte // AEAD ciphertext; opaque at this layer
DefaultPriority *string
CreatedAt time.Time
UpdatedAt time.Time
LastFiredAt *time.Time
}
// NotificationLogEntry is one row in notification_log.
type NotificationLogEntry struct {
ID string
ChannelID string
AlertID *string
Event string // alert.raised | alert.acknowledged | alert.resolved | alert.test
OK bool
StatusCode *int
LatencyMS *int
Error *string
FiredAt time.Time
}
// CreateNotificationChannel inserts a new notification channel row.
func (s *Store) CreateNotificationChannel(ctx context.Context, ch NotificationChannel) error {
enabled := 0
if ch.Enabled {
enabled = 1
}
_, err := s.db.ExecContext(ctx,
`INSERT INTO notification_channels
(id, kind, name, enabled, config, default_priority, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
ch.ID, ch.Kind, ch.Name, enabled, ch.Config,
nullable(ch.DefaultPriority),
ch.CreatedAt.UTC().Format(time.RFC3339Nano),
ch.UpdatedAt.UTC().Format(time.RFC3339Nano))
if err != nil {
return fmt.Errorf("store: create channel: %w", err)
}
return nil
}
// UpdateNotificationChannel updates mutable fields on an existing channel row.
func (s *Store) UpdateNotificationChannel(ctx context.Context, ch NotificationChannel) error {
enabled := 0
if ch.Enabled {
enabled = 1
}
_, err := s.db.ExecContext(ctx,
`UPDATE notification_channels
SET kind = ?, name = ?, enabled = ?, config = ?,
default_priority = ?, updated_at = ?
WHERE id = ?`,
ch.Kind, ch.Name, enabled, ch.Config,
nullable(ch.DefaultPriority),
ch.UpdatedAt.UTC().Format(time.RFC3339Nano),
ch.ID)
if err != nil {
return fmt.Errorf("store: update channel: %w", err)
}
return nil
}
// SetNotificationChannelEnabled flips the enabled flag without
// touching kind/name/config — used by the inline list-row toggle.
func (s *Store) SetNotificationChannelEnabled(ctx context.Context, id string, enabled bool, when time.Time) error {
v := 0
if enabled {
v = 1
}
_, err := s.db.ExecContext(ctx,
`UPDATE notification_channels SET enabled = ?, updated_at = ? WHERE id = ?`,
v, when.UTC().Format(time.RFC3339Nano), id)
if err != nil {
return fmt.Errorf("store: set channel enabled: %w", err)
}
return nil
}
// DeleteNotificationChannel removes a channel row; cascades to notification_log.
func (s *Store) DeleteNotificationChannel(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx,
`DELETE FROM notification_channels WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("store: delete channel: %w", err)
}
return nil
}
// GetNotificationChannel returns one channel by primary key or ErrNotFound.
func (s *Store) GetNotificationChannel(ctx context.Context, id string) (*NotificationChannel, error) {
row := s.db.QueryRowContext(ctx,
`SELECT id, kind, name, enabled, config, default_priority,
created_at, updated_at, last_fired_at
FROM notification_channels WHERE id = ?`, id)
return scanChannel(row.Scan)
}
// ListNotificationChannels returns all channels ordered by created_at ascending.
func (s *Store) ListNotificationChannels(ctx context.Context) ([]NotificationChannel, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, kind, name, enabled, config, default_priority,
created_at, updated_at, last_fired_at
FROM notification_channels ORDER BY created_at ASC`)
if err != nil {
return nil, fmt.Errorf("store: list channels: %w", err)
}
defer func() { _ = rows.Close() }()
var out []NotificationChannel
for rows.Next() {
c, err := scanChannel(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *c)
}
return out, rows.Err()
}
// ListEnabledNotificationChannels returns only channels with enabled=1, ordered by created_at.
func (s *Store) ListEnabledNotificationChannels(ctx context.Context) ([]NotificationChannel, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, kind, name, enabled, config, default_priority,
created_at, updated_at, last_fired_at
FROM notification_channels WHERE enabled = 1 ORDER BY created_at ASC`)
if err != nil {
return nil, fmt.Errorf("store: list enabled: %w", err)
}
defer func() { _ = rows.Close() }()
var out []NotificationChannel
for rows.Next() {
c, err := scanChannel(rows.Scan)
if err != nil {
return nil, err
}
out = append(out, *c)
}
return out, rows.Err()
}
// AppendNotificationLog records a delivery attempt + bumps the
// channel's last_fired_at on success.
func (s *Store) AppendNotificationLog(ctx context.Context, e NotificationLogEntry) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("store: begin: %w", err)
}
defer func() { _ = tx.Rollback() }()
ok := 0
if e.OK {
ok = 1
}
_, err = tx.ExecContext(ctx,
`INSERT INTO notification_log
(id, channel_id, alert_id, event, ok, status_code, latency_ms, error, fired_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
e.ID, e.ChannelID, nullable(e.AlertID), e.Event, ok,
nullableInt(e.StatusCode), nullableInt(e.LatencyMS),
nullable(e.Error),
e.FiredAt.UTC().Format(time.RFC3339Nano))
if err != nil {
return fmt.Errorf("store: append notification_log: %w", err)
}
if e.OK {
if _, err := tx.ExecContext(ctx,
`UPDATE notification_channels SET last_fired_at = ? WHERE id = ?`,
e.FiredAt.UTC().Format(time.RFC3339Nano), e.ChannelID); err != nil {
return fmt.Errorf("store: bump last_fired_at: %w", err)
}
}
return tx.Commit()
}
func scanChannel(scan func(...any) error) (*NotificationChannel, error) {
var c NotificationChannel
var enabled int
var defaultPri, lastFired sql.NullString
var createdAt, updatedAt string
if err := scan(&c.ID, &c.Kind, &c.Name, &enabled, &c.Config,
&defaultPri, &createdAt, &updatedAt, &lastFired); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("store: scan channel: %w", err)
}
c.Enabled = enabled == 1
if defaultPri.Valid {
v := defaultPri.String
c.DefaultPriority = &v
}
t, err := time.Parse(time.RFC3339Nano, createdAt)
if err != nil {
return nil, fmt.Errorf("store: parse created_at: %w", err)
}
c.CreatedAt = t
t, err = time.Parse(time.RFC3339Nano, updatedAt)
if err != nil {
return nil, fmt.Errorf("store: parse updated_at: %w", err)
}
c.UpdatedAt = t
if lastFired.Valid {
t, _ := time.Parse(time.RFC3339Nano, lastFired.String)
c.LastFiredAt = &t
}
return &c, nil
}
@@ -0,0 +1,96 @@
package store
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/oklog/ulid/v2"
)
func TestNotificationChannelCRUD(t *testing.T) {
t.Parallel()
dir := t.TempDir()
st, err := Open(context.Background(), filepath.Join(dir, "rm.db"))
if err != nil {
t.Fatalf("open: %v", err)
}
defer st.Close()
ctx := context.Background()
ch := NotificationChannel{
ID: ulid.Make().String(), Kind: "webhook", Name: "team-slack",
Enabled: true, Config: []byte("encrypted-blob"),
CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
}
if err := st.CreateNotificationChannel(ctx, ch); err != nil {
t.Fatalf("create: %v", err)
}
got, err := st.GetNotificationChannel(ctx, ch.ID)
if err != nil {
t.Fatalf("get: %v", err)
}
if got.Name != ch.Name || got.Kind != "webhook" || string(got.Config) != "encrypted-blob" {
t.Fatalf("got %+v", got)
}
got.Name = "team-slack-renamed"
got.Enabled = false
got.UpdatedAt = time.Now().UTC()
if err := st.UpdateNotificationChannel(ctx, *got); err != nil {
t.Fatalf("update: %v", err)
}
got2, _ := st.GetNotificationChannel(ctx, ch.ID)
if got2.Name != "team-slack-renamed" || got2.Enabled {
t.Fatalf("update not applied: %+v", got2)
}
all, _ := st.ListEnabledNotificationChannels(ctx)
if len(all) != 0 {
t.Errorf("disabled channel returned by ListEnabled: %d", len(all))
}
if err := st.DeleteNotificationChannel(ctx, ch.ID); err != nil {
t.Fatalf("delete: %v", err)
}
if _, err := st.GetNotificationChannel(ctx, ch.ID); err == nil {
t.Errorf("expected ErrNotFound after delete")
}
}
func TestAppendNotificationLog(t *testing.T) {
t.Parallel()
dir := t.TempDir()
st, _ := Open(context.Background(), filepath.Join(dir, "rm.db"))
defer st.Close()
ctx := context.Background()
chID := ulid.Make().String()
if err := st.CreateNotificationChannel(ctx, NotificationChannel{
ID: chID, Kind: "ntfy", Name: "n", Enabled: true,
Config: []byte{1, 2, 3},
CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("create channel: %v", err)
}
code := 200
lat := 287
if err := st.AppendNotificationLog(ctx, NotificationLogEntry{
ID: ulid.Make().String(), ChannelID: chID, Event: "alert.test",
OK: true, StatusCode: &code, LatencyMS: &lat,
FiredAt: time.Now().UTC(),
}); err != nil {
t.Fatalf("append: %v", err)
}
// LastFiredAt projection: the channel's last_fired_at is updated
// either by the append helper or by the callers; if you choose the
// helper does the bump, assert it.
got, _ := st.GetNotificationChannel(ctx, chID)
if got.LastFiredAt == nil {
t.Errorf("last_fired_at should bump on AppendNotificationLog success")
}
}
+14
View File
@@ -193,6 +193,20 @@ type EnrollmentToken struct {
ExpiresAt time.Time
}
// Alert mirrors the alerts table.
type Alert struct {
ID string
HostID *string
Kind string
Severity string
Message string
CreatedAt time.Time
LastSeenAt *time.Time
AcknowledgedAt *time.Time
AcknowledgedBy *string
ResolvedAt *time.Time
}
// AuditEntry mirrors the audit_log table.
type AuditEntry struct {
ID string
+6 -4
View File
@@ -270,11 +270,13 @@ Sizes: **S** = under a day, **M** = 13 days, **L** = 37 days.
> **As shipped (Playwright sweep against the live smoke env, 2026-05-04):** login → host detail → Restore button → wizard step 1 picks snapshot a1ac4006 (most recent) → tree drill-down `/home/steve/test` (3 lazy loads) → tick `file1` + `file2` → step 4 confirm summary populated → dispatch → live job page with running progress widget → restore succeeds, files land on disk at `/root/rm-restore/<job-id>/home/steve/test/file{1,2}` (default `$HOME/rm-restore/<job-id>/` after agent-side expansion). Custom-target restore to `/tmp/custom-restore/<job-id>/` lands inside the agent's `PrivateTmp` namespace. Snapshot diff between `a1ac4006` and `5f78c788` → diff job page, statistics output streamed (738 bytes added, 0 removed). Recent-restores line on host detail reads "last restore · succeeded 28s ago · job log →". Download dropdown serves both `.txt` and `.ndjson` with correct `Content-Type` + `Content-Disposition`. SIZE/FILES tooltip "Needs restic 0.17+ on the agent host. This host runs 0.16.4." renders on column hover.
### Phase 3 — Alerts (not started)
### Phase 3 — Alerts
- [ ] **P3-05** (M) Alert engine: rule evaluation loop (failed backup, stale schedule, agent offline, check failed)
- [ ] **P3-06** (M) Notification channels: webhook, ntfy, SMTP email
- [ ] **P3-07** (S) Alert UI: list, acknowledge, resolve
- [x] **P3-05** (M) Alert engine: rule evaluation loop (failed backup, stale schedule, agent offline, check failed)
- [x] **P3-06** (M) Notification channels: webhook, ntfy, SMTP email
- [x] **P3-07** (S) Alert UI: list, acknowledge, resolve
> **As shipped (Playwright sweep, 2026-05-04):** /settings/notifications → 3 channels created (sweep-webhook → local Python sink, sweep-ntfy → ntfy.sh public topic, sweep-smtp → MailHog at 127.0.0.1:1025). Test buttons fire alert.test on each: webhook 200/1ms, ntfy 200/322ms, SMTP 250/3ms. Synthetic critical `backup_failed` raised → /alerts shows row with severity dot, kind chip, host, message, raised/last-seen, Ack + Resolve buttons; nav badge `1`; dashboard critical-alert banner appears with Review→ link; OPEN ALERTS card reads `1 unresolved`. Acknowledge → fan-out to all 3 channels emits alert.acknowledged (verified in webhook sink, MailHog inbox, notification_log); Acknowledged tab shows row with `ack'd by <user>` line. Resolve → fan-out emits alert.resolved across all 3 channels; banner clears; dashboard reads `0 unresolved · all clear`; host alerts column reads —. Three live bugs found and fixed mid-sweep: (a) `enabled` form value lost because hidden+checkbox both named `enabled` and `PostForm.Get` returned the first ("0"); (b) Ack/Resolve handlers stored the state change but never dispatched alert.acknowledged / alert.resolved; (c) `hosts.open_alert_count` projection was never recomputed on Raise/Resolve/AutoResolve, so the dashboard count always read 0.
### Phase 3 — Audit log UI (not started)
File diff suppressed because one or more lines are too long
+132
View File
@@ -278,6 +278,39 @@
}
.snap-row.head:hover { background: transparent; }
/* ---------- alert rows (/alerts list) ---------- */
.alert-row {
display: grid; align-items: center;
grid-template-columns: 18px 110px 130px 1fr 130px 110px 180px;
column-gap: 16px;
padding: 12px 16px; font-size: 13px;
border-bottom: 1px solid var(--line-soft);
border-left: 3px solid transparent;
transition: background 100ms ease;
}
.alert-row:hover { background: var(--panel-hi); }
.alert-row:last-child { border-bottom: 0; }
.alert-row.head {
cursor: default; padding-top: 9px; padding-bottom: 9px;
font-size: 11px; color: var(--ink-fade);
text-transform: uppercase; letter-spacing: 0.08em;
border-left-color: transparent;
}
.alert-row.head:hover { background: transparent; }
.alert-row.severity-warn { border-left-color: color-mix(in oklch, var(--warn), transparent 50%); }
.alert-row.severity-critical { border-left-color: color-mix(in oklch, var(--bad), transparent 30%); }
.alert-row.resolved { opacity: 0.55; }
/* status-dot aliases for alert severity */
.dot-warn { background: var(--warn); box-shadow: 0 0 0 3px color-mix(in oklch, var(--warn), transparent 80%); }
.dot-critical { background: var(--bad); box-shadow: 0 0 0 3px color-mix(in oklch, var(--bad), transparent 80%); }
.dot-resolved { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in oklch, var(--ok), transparent 80%); }
/* tag colour variants for alerts */
.tag-warn { color: var(--warn); border-color: color-mix(in oklch, var(--warn), transparent 60%); background: color-mix(in oklch, var(--warn), transparent 92%); }
.tag-critical { color: var(--bad); border-color: color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%); }
.tag-info { color: var(--ink-mid); }
/* ---------- schedule rows (Schedules tab) ---------- */
.schd-row {
display: grid; align-items: center;
@@ -418,4 +451,103 @@
radial-gradient(ellipse at top, color-mix(in oklch, var(--accent), transparent 95%), transparent 60%),
var(--panel);
}
/* ---------- notification channel rows (/settings/notifications) ---------- */
.ch-row {
display: grid; align-items: center;
grid-template-columns: 28px 200px 1fr 100px 130px 140px;
column-gap: 16px;
padding: 14px 18px; font-size: 13px;
border-bottom: 1px solid var(--line-soft);
transition: background 100ms ease;
}
.ch-row:last-child { border-bottom: 0; }
.ch-row.head {
cursor: default; font-size: 11px; color: var(--ink-fade);
text-transform: uppercase; letter-spacing: 0.08em;
padding-top: 10px; padding-bottom: 10px;
}
.ch-row.head:hover { background: transparent; }
/* Whole-row click → edit page (mirrors .host-row.clickable). */
.ch-row.clickable { position: relative; cursor: pointer; }
.ch-row.clickable .row-link {
position: absolute; inset: 0; z-index: 0;
text-indent: -9999px; overflow: hidden;
}
.ch-row.clickable:hover { background: var(--panel-hi); }
.ch-row.clickable > * { position: relative; z-index: 1; pointer-events: none; }
.ch-row.clickable > .row-link { pointer-events: auto; }
.ch-row.clickable > .row-action { pointer-events: auto; }
/* Channel kind icons */
.ch-icon {
width: 24px; height: 24px;
border-radius: 5px;
display: inline-flex; align-items: center; justify-content: center;
font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: 600;
background: var(--panel-hi); color: var(--ink-mute);
border: 1px solid var(--line);
}
.ch-icon.webhook { color: var(--accent); border-color: color-mix(in oklch, var(--accent), transparent 60%); }
.ch-icon.ntfy { color: var(--warn); border-color: color-mix(in oklch, var(--warn), transparent 60%); }
.ch-icon.smtp { color: var(--ok); border-color: color-mix(in oklch, var(--ok), transparent 60%); }
/* ---------- toggle (enabled/disabled switch) ---------- */
.toggle {
display: inline-block; width: 30px; height: 16px; border-radius: 9999px;
background: var(--line); position: relative; cursor: pointer;
transition: background 120ms ease; flex-shrink: 0;
}
.toggle::after {
content: ""; position: absolute; left: 2px; top: 2px;
width: 12px; height: 12px; border-radius: 9999px;
background: var(--ink-mid);
transition: all 120ms ease;
}
.toggle.on { background: color-mix(in oklch, var(--accent), transparent 50%); }
.toggle.on::after { left: 16px; background: var(--accent); }
/* ---------- kind-picker radio cards (channel edit form) ---------- */
.kind-grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 14px; }
.kind-card {
border: 1px solid var(--line-soft); background: var(--bg);
border-radius: 7px; padding: 16px;
cursor: pointer;
transition: border-color 120ms ease, background 120ms ease;
}
.kind-card:hover { border-color: var(--ink-mute); }
.kind-card.selected {
border-color: color-mix(in oklch, var(--accent), transparent 50%);
background: color-mix(in oklch, var(--accent), transparent 95%);
}
/* Radio pip inside kind cards */
.radio-pip {
width: 14px; height: 14px;
border-radius: 9999px;
border: 1px solid var(--line);
display: inline-flex; align-items: center; justify-content: center;
flex-shrink: 0;
}
.radio-pip.on { border-color: var(--accent); }
.radio-pip.on::after {
content: ""; width: 6px; height: 6px; border-radius: 9999px;
background: var(--accent);
}
/* ---------- test-result pills (notification test button) ---------- */
.test-pill {
display: inline-block;
padding: 5px 10px; border-radius: 5px; font-size: 12.5px;
}
.test-pill-ok {
border: 1px solid color-mix(in oklch, var(--ok), transparent 60%);
background: color-mix(in oklch, var(--ok), transparent 92%);
color: var(--ok);
}
.test-pill-fail {
border: 1px solid color-mix(in oklch, var(--bad), transparent 60%);
background: color-mix(in oklch, var(--bad), transparent 92%);
color: var(--bad);
}
}
+122
View File
@@ -0,0 +1,122 @@
{{define "title"}}Alerts · restic-manager{{end}}
{{define "content"}}
{{$page := .Page}}
{{$filter := $page.Filter}}
<div class="max-w-[1280px] mx-auto px-8 pb-14">
{{/* crumbs */}}
<div class="crumbs pt-6">
<a href="/">Dashboard</a><span class="sep">/</span>
<span class="text-ink-mid">alerts</span>
</div>
{{/* page header */}}
<div class="flex items-baseline justify-between mt-3.5">
<div>
<h1 class="text-[22px] font-medium tracking-[-0.005em]">
Alerts
<span class="text-ink-fade font-normal text-[14px] ml-2">
{{$page.Counts.Open}} open
{{if gt $page.Counts.Acknowledged 0}} · {{$page.Counts.Acknowledged}} acknowledged{{end}}
· {{$page.Counts.Resolved24h}} resolved (24h)
</span>
</h1>
</div>
<div class="flex gap-2">
<a href="/settings/notifications" class="btn">Channel settings →</a>
</div>
</div>
{{/* filter strip */}}
<div class="panel mt-4 px-4 py-3 rounded-[7px]"
style="display: grid; grid-template-columns: auto auto auto 1fr; gap: 14px; align-items: center;">
{{/* status pills */}}
<div class="inline-flex gap-1 p-[3px]" style="border: 1px solid var(--line-soft); border-radius: 5px;">
{{range list "open" "acknowledged" "resolved" "all"}}
{{$s := .}}
{{$active := eq $s $filter.Status}}
{{if and (eq $s "all") (eq $filter.Status "")}}{{$active = true}}{{end}}
<a href="/alerts?status={{$s}}{{if $filter.Severity}}&severity={{$filter.Severity}}{{end}}{{if $filter.HostID}}&host_id={{$filter.HostID}}{{end}}{{if $filter.Search}}&q={{$filter.Search}}{{end}}"
class="btn btn-ghost"
style="padding: 5px 10px; font-size: 11.5px;{{if $active}} background: var(--panel-hi); color: var(--ink);{{end}}">
{{if eq $s "open"}}Open <span class="text-ink-fade mono ml-1">{{$page.Counts.Open}}</span>
{{else if eq $s "acknowledged"}}Acknowledged <span class="text-ink-fade mono ml-1">{{$page.Counts.Acknowledged}}</span>
{{else if eq $s "resolved"}}Resolved <span class="text-ink-fade mono ml-1">{{$page.Counts.Resolved24h}}</span>
{{else}}All{{end}}
</a>
{{end}}
</div>
{{/* severity dropdown */}}
<div>
<select class="field" style="padding: 6px 10px; font-size: 11.5px; min-width: 130px;"
onchange="window.location='/alerts?status={{$filter.Status}}&severity='+this.value+'{{if $filter.HostID}}&host_id={{$filter.HostID}}{{end}}{{if $filter.Search}}&q={{$filter.Search}}{{end}}'">
<option value="" {{if eq $filter.Severity ""}}selected{{end}}>Severity · any</option>
<option value="info" {{if eq $filter.Severity "info"}}selected{{end}}>info</option>
<option value="warning" {{if eq $filter.Severity "warning"}}selected{{end}}>warning</option>
<option value="critical" {{if eq $filter.Severity "critical"}}selected{{end}}>critical</option>
</select>
</div>
{{/* host dropdown */}}
<div>
<select class="field" style="padding: 6px 10px; font-size: 11.5px; min-width: 160px;"
onchange="window.location='/alerts?status={{$filter.Status}}{{if $filter.Severity}}&severity={{$filter.Severity}}{{end}}&host_id='+this.value+'{{if $filter.Search}}&q={{$filter.Search}}{{end}}'">
<option value="" {{if eq $filter.HostID ""}}selected{{end}}>Host · all</option>
{{range $id, $name := $page.HostNames}}
<option value="{{$id}}" {{if eq $filter.HostID $id}}selected{{end}}>{{$name}}</option>
{{end}}
</select>
</div>
{{/* search input */}}
<form method="get" action="/alerts">
<input type="hidden" name="status" value="{{$filter.Status}}">
{{if $filter.Severity}}<input type="hidden" name="severity" value="{{$filter.Severity}}">{{end}}
{{if $filter.HostID}}<input type="hidden" name="host_id" value="{{$filter.HostID}}">{{end}}
<input type="text" name="q" value="{{$filter.Search}}"
placeholder="search message…"
class="field mono"
style="padding: 6px 10px; font-size: 11.5px;">
</form>
</div>
{{/* alerts table */}}
<div class="panel mt-3.5 rounded-[7px] overflow-hidden">
{{/* header row */}}
<div class="alert-row head">
<div></div>
<div>Severity / kind</div>
<div>Host</div>
<div>Message</div>
<div>Raised</div>
<div>Last seen</div>
<div></div>
</div>
{{if eq (len $page.Alerts) 0}}
{{/* empty state */}}
<div style="padding: 40px; text-align: center;">
<div class="inline-flex items-center gap-3.5">
<span class="dot dot-online" style="width: 10px; height: 10px;"></span>
<div style="text-align: left;">
<div class="text-ink text-[14px] font-medium">All clear.</div>
<div class="text-ink-mute text-[12px] mt-0.5">
No alerts match the current filter.
</div>
</div>
</div>
</div>
{{else}}
{{range $page.Alerts}}
{{template "alert_row" (dict "Alert" . "HostNames" $page.HostNames "Filter" $page.Filter)}}
{{end}}
{{end}}
</div>
</div>
{{end}}
+1
View File
@@ -4,6 +4,7 @@
<div class="max-w-[1280px] mx-auto px-8">
{{$page := .Page}}
{{template "crit_banner" .Page}}
{{if eq $page.HostCount 0}}
{{/* ---------- empty state ---------- */}}
@@ -0,0 +1,9 @@
{{/* notification_edit.html — rendered by handleUINotificationEditGet/Post via Render("settings", …).
This file exists so the glob-discovered page registry includes it cleanly.
The actual edit form lives in settings.html's notification_edit_form block. */}}
{{define "title"}}Edit Channel · Settings · restic-manager{{end}}
{{define "content"}}
{{/* This page is served under the "settings" renderer key; this file is a
placeholder discovered by the glob so ui.New() registers "notification_edit"
as a valid page. Handlers do not call Render("notification_edit", …) directly. */}}
{{end}}
+9
View File
@@ -0,0 +1,9 @@
{{/* notifications.html — rendered by handleUINotificationsList via Render("settings", …).
This file exists so the glob-discovered page registry includes it cleanly.
The actual list body lives in settings.html's notification_list_body block. */}}
{{define "title"}}Notifications · Settings · restic-manager{{end}}
{{define "content"}}
{{/* This page is served under the "settings" renderer key; this file is a
placeholder discovered by the glob so ui.New() registers "notifications"
as a valid page. Handlers do not call Render("notifications", …) directly. */}}
{{end}}
+590
View File
@@ -0,0 +1,590 @@
{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{$page := .Page}}
<div class="max-w-[1280px] mx-auto px-8 pt-7 pb-14">
{{/* ---------- breadcrumbs ---------- */}}
<div class="crumbs">
<a href="/">Dashboard</a><span class="sep">/</span>
{{if $page.Form}}
<a href="/settings">Settings</a><span class="sep">/</span>
<a href="/settings/notifications">notifications</a><span class="sep">/</span>
{{if $page.Form.ID}}
<span class="text-ink-mid">{{$page.Form.Name}}</span>
{{else}}
<span class="text-ink-mid">new channel</span>
{{end}}
{{else}}
<a href="/settings">Settings</a><span class="sep">/</span>
<span class="text-ink-mid">notifications</span>
{{end}}
</div>
{{/* ---------- page header ---------- */}}
<div class="flex items-baseline justify-between mt-3.5">
{{if $page.Form}}
<h1 class="text-[22px] font-medium tracking-[-0.005em]">
{{if $page.Form.ID}}Edit channel · <span class="mono text-ink-mid">{{$page.Form.Name}}</span>{{else}}Add channel{{end}}
</h1>
{{else}}
<h1 class="text-[22px] font-medium tracking-[-0.005em]">Settings</h1>
<a href="/settings/notifications/new" class="btn btn-primary">+ Add channel</a>
{{end}}
</div>
{{/* ---------- sub-tab nav ---------- */}}
<div class="flex items-end mt-3.5 border-b border-line-soft">
<a class="sub-tab {{if eq $page.ActiveTab "notifications"}}active{{end}}" href="/settings/notifications">
Notifications
{{if not $page.Form}}<span class="mono text-ink-fade text-[11px] ml-1">{{len $page.Channels}}</span>{{end}}
</a>
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Users</span>
<span class="sub-tab text-ink-fade cursor-default" title="lands later">Authentication</span>
</div>
{{/* ---------- sub-tab body ---------- */}}
<div class="mt-5">
{{if $page.Form}}
{{template "notification_edit_form" $page}}
{{else}}
{{template "notification_list_body" $page}}
{{end}}
</div>
</div>
{{end}}
{{/* ================================================================
notification_list_body — channel list (embedded in settings.html)
Receives $page (settingsPage).
================================================================ */}}
{{define "notification_list_body"}}
<p class="text-[12.5px] text-ink-mute mb-4 leading-[1.6] max-w-[720px]">
Notification channels fire when the alert engine raises an alert.
All channels apply globally — every alert that meets the engine's thresholds is sent to every enabled channel.
</p>
{{if not .Channels}}
<div class="empty-state mt-2">
<h3 class="text-[16px] font-medium">No channels configured.</h3>
<p class="text-ink-mute text-[13px] mt-2 mx-auto max-w-[480px] leading-[1.6]">
Alerts are still raised in the dashboard, but nothing is pushed to chat / phone / email.
Add a channel to get notified.
</p>
<div class="mt-5">
<a href="/settings/notifications/new" class="btn btn-primary">+ Add your first channel</a>
</div>
</div>
{{else}}
<div class="panel rounded-[7px] overflow-hidden">
<div class="ch-row head">
<div></div>
<div>Name</div>
<div>Endpoint</div>
<div>Enabled</div>
<div>Last fired</div>
<div></div>
</div>
{{range .Channels}}
{{$ch := .}}
<div class="ch-row clickable">
<a class="row-link" href="/settings/notifications/{{$ch.ID}}/edit">edit {{$ch.Name}}</a>
<div>
{{if eq $ch.Kind "webhook"}}<span class="ch-icon webhook">WH</span>
{{else if eq $ch.Kind "ntfy"}}<span class="ch-icon ntfy">NT</span>
{{else}}<span class="ch-icon smtp">@</span>{{end}}
</div>
<div class="text-ink font-medium">{{$ch.Name}}</div>
<div class="mono text-ink-mute" style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">
{{if eq $ch.Kind "webhook"}}webhook · click to edit{{else if eq $ch.Kind "ntfy"}}ntfy · click to edit{{else}}smtp · click to edit{{end}}
</div>
<div class="row-action">
{{if $ch.Enabled}}
<span class="toggle on" hx-post="/settings/notifications/{{$ch.ID}}/toggle"
hx-target="this" hx-swap="outerHTML"
onclick="event.stopPropagation()" style="cursor:pointer"
title="click to disable"></span>
{{else}}
<span class="toggle" hx-post="/settings/notifications/{{$ch.ID}}/toggle"
hx-target="this" hx-swap="outerHTML"
onclick="event.stopPropagation()" style="cursor:pointer"
title="click to enable"></span>
{{end}}
</div>
<div class="mono text-ink-mid">
{{if $ch.LastFiredAt}}{{relTime $ch.LastFiredAt}}{{else}}<span class="text-ink-fade">never</span>{{end}}
</div>
<div class="row-action flex gap-1.5 justify-end">
<a href="/settings/notifications/{{$ch.ID}}/edit" class="btn">Edit</a>
<a href="/settings/notifications/{{$ch.ID}}/edit#delete-panel" class="btn btn-danger">Delete</a>
</div>
</div>
{{end}}
</div>
{{end}}
{{end}}
{{/* ================================================================
notification_edit_form — create/edit form (embedded in settings.html)
Receives $page (settingsPage).
================================================================ */}}
{{define "notification_edit_form"}}
{{$f := .Form}}
{{$isEdit := ne $f.ID ""}}
{{if .FormError}}
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
{{.FormError}}
</div>
{{end}}
{{if .DeleteError}}
<div class="rounded-[6px] px-3.5 py-3 text-[13px] mb-4"
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%); background: color-mix(in oklch, var(--bad), transparent 92%);">
{{.DeleteError}}
</div>
{{end}}
<div class="grid gap-6 items-start" style="grid-template-columns: 1fr 380px;">
<div>
{{/* ---------- kind picker ---------- */}}
<div class="mb-5">
<div class="field-label mb-2.5">Channel kind</div>
<div class="kind-grid">
{{/* Webhook card */}}
<label class="kind-card {{if eq $f.Kind "webhook"}}selected{{end}}">
<input type="radio" name="kind" value="webhook" class="hidden kind-radio"
{{if eq $f.Kind "webhook"}}checked{{end}} />
<div class="flex items-center gap-3">
<span class="radio-pip {{if eq $f.Kind "webhook"}}on{{end}}"></span>
<span class="ch-icon webhook">WH</span>
<div>
<div class="text-ink text-[13px] font-medium">Webhook</div>
<div class="text-ink-mute text-[11.5px] mt-0.5">POST a JSON envelope to your URL.</div>
</div>
</div>
</label>
{{/* Ntfy card */}}
<label class="kind-card {{if eq $f.Kind "ntfy"}}selected{{end}}" style="{{if eq $f.Kind "ntfy"}}border-color: color-mix(in oklch, var(--warn), transparent 50%); background: color-mix(in oklch, var(--warn), transparent 95%);{{end}}">
<input type="radio" name="kind" value="ntfy" class="hidden kind-radio"
{{if eq $f.Kind "ntfy"}}checked{{end}} />
<div class="flex items-center gap-3">
<span class="radio-pip {{if eq $f.Kind "ntfy"}}on{{end}}" style="{{if eq $f.Kind "ntfy"}}border-color: var(--warn);{{end}}"></span>
<span class="ch-icon ntfy">NT</span>
<div>
<div class="text-ink text-[13px] font-medium">Ntfy</div>
<div class="text-ink-mute text-[11.5px] mt-0.5">Push to ntfy.sh or your self-hosted ntfy server.</div>
</div>
</div>
</label>
{{/* SMTP card */}}
<label class="kind-card {{if eq $f.Kind "smtp"}}selected{{end}}" style="{{if eq $f.Kind "smtp"}}border-color: color-mix(in oklch, var(--ok), transparent 50%); background: color-mix(in oklch, var(--ok), transparent 95%);{{end}}">
<input type="radio" name="kind" value="smtp" class="hidden kind-radio"
{{if eq $f.Kind "smtp"}}checked{{end}} />
<div class="flex items-center gap-3">
<span class="radio-pip {{if eq $f.Kind "smtp"}}on{{end}}" style="{{if eq $f.Kind "smtp"}}border-color: var(--ok);{{end}}"></span>
<span class="ch-icon smtp">@</span>
<div>
<div class="text-ink text-[13px] font-medium">SMTP email</div>
<div class="text-ink-mute text-[11.5px] mt-0.5">Plain-text email via your SMTP relay.</div>
</div>
</div>
</label>
</div>
</div>
{{/* ---------- per-kind fields ---------- */}}
{{if $isEdit}}
<form method="post" action="/settings/notifications/{{$f.ID}}/edit" id="ch-form">
{{else}}
<form method="post" action="/settings/notifications/new" id="ch-form">
{{end}}
{{/* hidden kind field updated by JS */}}
<input type="hidden" name="kind" id="kind-hidden" value="{{$f.Kind}}" />
{{/* Webhook fields */}}
<div id="fields-webhook" class="{{if ne $f.Kind "webhook"}}hidden{{end}}">
<div class="panel rounded-[7px] p-[18px] space-y-4">
<div>
<label class="field-label" for="wh-name">Name</label>
<input id="wh-name" name="name" type="text" class="field"
value="{{if eq $f.Kind "webhook"}}{{$f.Name}}{{end}}"
placeholder="e.g. team-slack-bridge" />
<div class="field-help">Operator-friendly label shown in the channel list and audit log.</div>
</div>
<div>
<label class="field-label" for="wh-url">URL</label>
<input id="wh-url" name="webhook_url" type="url" class="field mono"
value="{{$f.WebhookURL}}" placeholder="https://hooks.example.com/…" />
<div class="field-help">We POST the JSON envelope shown on the right. 5s timeout; failures are logged but not retried.</div>
</div>
<div>
<label class="field-label" for="wh-bearer">Bearer token <span class="text-ink-fade font-normal">· optional</span></label>
<input id="wh-bearer" name="webhook_bearer_token" type="password" class="field mono"
placeholder="{{if and $isEdit (eq $f.Kind "webhook")}}•••••••• · stored, leave blank to keep{{else}}leave blank if not needed{{end}}" />
<div class="field-help">If set, sent as <span class="mono text-ink-mid">Authorization: Bearer …</span> on every POST.</div>
</div>
<div>
<label class="field-label">Custom header <span class="text-ink-fade font-normal">· optional</span></label>
<div class="grid grid-cols-2 gap-2.5">
<input type="text" name="webhook_header_name" class="field mono"
value="{{$f.WebhookHeaderName}}" placeholder="X-Header-Name" />
<input type="text" name="webhook_header_value" class="field mono"
placeholder="value" />
</div>
<div class="field-help">Single extra header in v1.</div>
</div>
</div>
</div>
{{/* Ntfy fields */}}
<div id="fields-ntfy" class="{{if ne $f.Kind "ntfy"}}hidden{{end}}">
<div class="panel rounded-[7px] p-[18px] space-y-4">
<div>
<label class="field-label" for="nt-name">Name</label>
<input id="nt-name" name="name" type="text" class="field"
value="{{if eq $f.Kind "ntfy"}}{{$f.Name}}{{end}}"
placeholder="e.g. phone-bzzt" />
</div>
<div class="grid grid-cols-2 gap-3.5">
<div>
<label class="field-label" for="nt-server">Server URL</label>
<input id="nt-server" name="ntfy_server_url" type="url" class="field mono"
value="{{if $f.NtfyServerURL}}{{$f.NtfyServerURL}}{{else}}https://ntfy.sh{{end}}" />
<div class="field-help">Default <span class="mono">https://ntfy.sh</span>; change for self-hosted.</div>
</div>
<div>
<label class="field-label" for="nt-topic">Topic</label>
<input id="nt-topic" name="ntfy_topic" type="text" class="field mono"
value="{{$f.NtfyTopic}}" placeholder="restic-manager-fleet" />
<div class="field-help">Subscribe to this topic in the ntfy app.</div>
</div>
</div>
<div>
<label class="field-label" for="nt-token">Access token <span class="text-ink-fade font-normal">· optional</span></label>
<input id="nt-token" name="ntfy_access_token" type="password" class="field mono"
placeholder="{{if and $isEdit (eq $f.Kind "ntfy")}}tk_… · stored, leave blank to keep{{else}}tk_… · required for protected topics{{end}}" />
<div class="field-help">Use this OR username+password below. Token wins when both are set.</div>
</div>
<div class="grid grid-cols-2 gap-3.5">
<div>
<label class="field-label" for="nt-user">Username <span class="text-ink-fade font-normal">· optional</span></label>
<input id="nt-user" name="ntfy_username" type="text" class="field mono"
value="{{$f.NtfyUsername}}" placeholder="ntfy basic-auth user" />
</div>
<div>
<label class="field-label" for="nt-pass">Password <span class="text-ink-fade font-normal">· optional</span></label>
<input id="nt-pass" name="ntfy_password" type="password" class="field mono"
placeholder="{{if and $isEdit (eq $f.Kind "ntfy")}}stored, leave blank to keep{{else}}ntfy basic-auth password{{end}}" />
<div class="field-help">Sent as HTTP Basic auth when no token is set.</div>
</div>
</div>
<div>
<label class="field-label" for="nt-priority">Default priority</label>
<select id="nt-priority" name="default_priority" class="field">
<option value="default" {{if eq $f.DefaultPriority "default"}}selected{{end}}>default</option>
<option value="min" {{if eq $f.DefaultPriority "min"}}selected{{end}}>min</option>
<option value="low" {{if eq $f.DefaultPriority "low"}}selected{{end}}>low</option>
<option value="high" {{if eq $f.DefaultPriority "high"}}selected{{end}}>high · maps to severity=warning</option>
<option value="urgent" {{if eq $f.DefaultPriority "urgent"}}selected{{end}}>urgent · maps to severity=critical</option>
</select>
<div class="field-help">Per-alert severity overrides this — critical alerts always go out at urgent regardless of the default.</div>
</div>
</div>
</div>
{{/* SMTP fields */}}
<div id="fields-smtp" class="{{if ne $f.Kind "smtp"}}hidden{{end}}">
<div class="panel rounded-[7px] p-[18px] space-y-4">
<div>
<label class="field-label" for="sm-name">Name</label>
<input id="sm-name" name="name" type="text" class="field"
value="{{if eq $f.Kind "smtp"}}{{$f.Name}}{{end}}"
placeholder="e.g. overnight-digest" />
<div class="field-help">One channel = one recipient — add another channel for a second mailbox.</div>
</div>
<div class="grid gap-3.5" style="grid-template-columns: 2fr 1fr 1fr;">
<div>
<label class="field-label" for="sm-host">SMTP host</label>
<input id="sm-host" name="smtp_host" type="text" class="field mono"
value="{{$f.SMTPHost}}" placeholder="smtp.example.com" />
</div>
<div>
<label class="field-label" for="sm-port">Port</label>
<input id="sm-port" name="smtp_port" type="number" class="field mono"
value="{{if $f.SMTPPort}}{{$f.SMTPPort}}{{else}}587{{end}}" min="1" max="65535" />
</div>
<div>
<label class="field-label" for="sm-enc">Encryption</label>
<select id="sm-enc" name="smtp_encryption" class="field">
<option value="starttls" {{if eq $f.SMTPEncryption "starttls"}}selected{{end}}>STARTTLS · 587</option>
<option value="tls" {{if eq $f.SMTPEncryption "tls"}}selected{{end}}>Implicit TLS · 465</option>
<option value="none" {{if eq $f.SMTPEncryption "none"}}selected{{end}}>None · 25 (plain)</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3.5">
<div>
<label class="field-label" for="sm-user">Username</label>
<input id="sm-user" name="smtp_username" type="text" class="field mono"
value="{{$f.SMTPUsername}}" placeholder="alerts@example.com" />
</div>
<div>
<label class="field-label" for="sm-pass">Password</label>
<input id="sm-pass" name="smtp_password" type="password" class="field mono"
placeholder="{{if and $isEdit (eq $f.Kind "smtp")}}•••••••• · stored, leave blank to keep{{else}}app password{{end}}" />
<div class="field-help">App password recommended for Gmail / M365.</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3.5">
<div>
<label class="field-label" for="sm-from">From</label>
<input id="sm-from" name="smtp_from" type="text" class="field mono"
value="{{$f.SMTPFrom}}" placeholder="Restic-Manager &lt;alerts@example.com&gt;" />
</div>
<div>
<label class="field-label" for="sm-to">To</label>
<input id="sm-to" name="smtp_to" type="text" class="field mono"
value="{{$f.SMTPTo}}" placeholder="ops@example.com" />
<div class="field-help">Single address or distribution list.</div>
</div>
</div>
</div>
</div>
{{/* ---------- enabled + test ---------- */}}
<div class="panel rounded-[7px] p-[18px] mt-3.5">
<div class="flex items-center gap-3">
<label class="flex items-center gap-3 cursor-pointer">
<input type="hidden" name="enabled" value="0" />
<input type="checkbox" name="enabled" value="1" {{if $f.Enabled}}checked{{end}}
class="hidden enabled-check" id="enabled-check" />
<span class="toggle {{if $f.Enabled}}on{{end}}" id="enabled-toggle"></span>
</label>
<div>
<div class="text-ink text-[13px]">Enabled</div>
<div class="text-ink-mute text-[11.5px] mt-0.5">When off, this channel is skipped on alert dispatch.</div>
</div>
</div>
{{if $isEdit}}
<div class="mt-4 pt-4 border-t border-line-soft">
<div class="flex items-center gap-3">
<button type="button" class="btn"
hx-post="/api/notifications/{{$f.ID}}/test"
hx-target="#test-result"
hx-swap="innerHTML">Send test notification</button>
<div id="test-result"></div>
</div>
<div class="text-[11.5px] text-ink-fade mt-2">
Sends severity=info, kind=test_notification, message="Test from restic-manager".
</div>
</div>
{{end}}
</div>
{{/* ---------- action row ---------- */}}
<div class="mt-5 flex items-center justify-between">
<a href="/settings/notifications" class="btn">Cancel</a>
<div class="flex gap-2">
{{if $isEdit}}
<button type="button" class="btn btn-danger" id="delete-btn"
onclick="document.getElementById('delete-panel').classList.toggle('hidden')">Delete channel…</button>
{{end}}
<button type="submit" class="btn btn-primary btn-lg">
{{if $isEdit}}Save changes{{else}}Create channel{{end}}
</button>
</div>
</div>
{{/* ---------- typed-confirm delete ---------- */}}
</form>{{/* close ch-form — delete panel must live OUTSIDE because HTML forbids nested forms */}}
{{if $isEdit}}
<div id="delete-panel" class="hidden mt-4 panel rounded-[7px] p-[18px]"
style="border-color: color-mix(in oklch, var(--bad), transparent 60%);">
<div class="text-[13px] font-medium text-bad mb-2">Delete channel</div>
<p class="text-[12.5px] text-ink-mute mb-3 leading-[1.55]">
Type the channel name to confirm permanent deletion. This cannot be undone.
</p>
<form method="post" action="/settings/notifications/{{$f.ID}}/delete">
<input type="text" name="confirm_name" class="field mono mb-3"
placeholder="{{$f.Name}}" autocomplete="off" />
<button type="submit" class="btn btn-danger">Delete permanently</button>
</form>
</div>
{{end}}
</div>
{{/* ---------- right rail — payload preview ----------
All three are rendered; the kind-switcher JS toggles which is
visible. Server-side {{if}} would freeze the panel at whichever
kind was loaded, so flipping the picker leaves it stale. */}}
<aside>
<div id="preview-webhook" class="{{if ne $f.Kind "webhook"}}hidden{{end}}">
<div class="panel rounded-[7px] p-4">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em] mb-2.5">Payload preview</div>
<div class="text-[12.5px] text-ink-mute mb-3 leading-[1.6]">
Every alert raise POSTs this JSON envelope.
Switch on <span class="mono text-ink-mid">severity</span> or
<span class="mono text-ink-mid">kind</span> in your bridge.
</div>
<div class="snippet">
<div class="snippet-head">application/json</div>
<pre>{
"event": "alert.raised",
"alert_id": "01KQTABCDEFGHJ",
"severity": "warning",
"kind": "backup_failed",
"host_id": "01KQPCD5T1QRYH9",
"host_name": "alfa-01",
"message": "Backup 'system-config' failed: …",
"raised_at": "2026-05-04T15:42:01Z",
"link": "https://restic-manager.example/alerts/01KQTABCDEFGHJ"
}</pre>
</div>
<div class="text-[11px] text-ink-fade mt-2.5 leading-[1.55]">
On <span class="mono text-ink-mid">alert.acknowledged</span> /
<span class="mono text-ink-mid">alert.resolved</span> the same shape is sent
with the updated event field.
</div>
</div>
</div>
<div id="preview-ntfy" class="{{if ne $f.Kind "ntfy"}}hidden{{end}}">
<div class="panel rounded-[7px] p-4">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em] mb-2.5">Ntfy delivery shape</div>
<div class="text-[12.5px] text-ink-mute mb-3 leading-[1.6]">
Ntfy POSTs use the standard publish format — title, body, click-URL, tags.
Tap the notification to open the alert in the UI.
</div>
<div class="snippet">
<div class="snippet-head">POST /&lt;topic&gt; HTTP/1.1</div>
<pre>Host: ntfy.sh
Title: [warning] alfa-01 backup failed
Priority: 4
Tags: warning,backup_failed
Click: https://restic-manager.example/alerts/01KQTABCDEFGHJ
Backup 'system-config' failed: rest-server returned 401</pre>
</div>
</div>
</div>
<div id="preview-smtp" class="{{if ne $f.Kind "smtp"}}hidden{{end}}">
<div class="panel rounded-[7px] p-4">
<div class="text-[11px] text-ink-fade uppercase tracking-[0.08em] mb-2.5">Email shape</div>
<div class="text-[12.5px] text-ink-mute mb-3 leading-[1.6]">
Plain text only in v1. Message-ID includes the alert id so the
raised → acknowledged → resolved exchange threads in mail clients.
</div>
<div class="snippet">
<div class="snippet-head">RFC 5322 message</div>
<pre>From: Restic-Manager &lt;alerts@example.com&gt;
To: ops-overnight@example.com
Subject: [restic-manager] [warning] alfa-01: backup_failed
Message-ID: &lt;01KQTABCDEFGHJ@restic-manager.example&gt;
Date: Mon, 04 May 2026 15:42:01 +0000
Backup 'system-config' failed: rest-server returned 401
Raised at: 2026-05-04T15:42:01Z
Severity: warning
Host: alfa-01
Kind: backup_failed
Open in restic-manager:
https://restic-manager.example/alerts/01KQTABCDEFGHJ</pre>
</div>
</div>
</div>
</aside>
</div>
{{/* JS: kind-picker interactivity + enabled toggle + HTMX test-result rendering */}}
<script>
(function() {
var kinds = ['webhook', 'ntfy', 'smtp'];
// Kind switcher
var radios = document.querySelectorAll('.kind-radio');
radios.forEach(function(radio) {
radio.closest('label').addEventListener('click', function() {
var kind = radio.value;
document.getElementById('kind-hidden').value = kind;
// Show/hide field panels + matching right-rail payload preview.
kinds.forEach(function(k) {
var fields = document.getElementById('fields-' + k);
var preview = document.getElementById('preview-' + k);
if (fields) fields .classList.toggle('hidden', k !== kind);
if (preview) preview.classList.toggle('hidden', k !== kind);
});
// Update card styles
radios.forEach(function(r) {
var card = r.closest('label');
var pip = card.querySelector('.radio-pip');
var k = r.value;
card.classList.toggle('selected', k === kind);
if (k === kind) {
r.checked = true;
if (pip) pip.classList.add('on');
if (k === 'webhook') { card.style.borderColor = ''; card.style.background = ''; }
else if (k === 'ntfy') { card.style.borderColor = 'color-mix(in oklch, var(--warn), transparent 50%)'; card.style.background = 'color-mix(in oklch, var(--warn), transparent 95%)'; }
else if (k === 'smtp') { card.style.borderColor = 'color-mix(in oklch, var(--ok), transparent 50%)'; card.style.background = 'color-mix(in oklch, var(--ok), transparent 95%)'; }
} else {
if (pip) pip.classList.remove('on');
pip && (pip.style.borderColor = '');
card.style.borderColor = '';
card.style.background = '';
}
});
});
});
// Enabled toggle: the visual <span class="toggle"> is inside a <label>
// wrapping a hidden checkbox, so clicking the span already flips the
// checkbox via the label's native behaviour. We only need to mirror
// that into the .on class — listening on the toggle's own click would
// race the label and cancel out, leaving check.checked at its original
// value (so Save would persist the unchanged setting).
var check = document.getElementById('enabled-check');
var tog = document.getElementById('enabled-toggle');
if (check && tog) {
check.addEventListener('change', function() {
tog.classList.toggle('on', check.checked);
});
}
// HTMX test-result: render JSON from the API as a coloured pill
document.body.addEventListener('htmx:afterRequest', function(evt) {
if (!evt.detail.elt || !evt.detail.elt.hasAttribute('hx-target')) return;
if (evt.detail.elt.getAttribute('hx-target') !== '#test-result') return;
var target = document.getElementById('test-result');
if (!target) return;
try {
var data = JSON.parse(evt.detail.xhr.responseText);
if (data.ok) {
target.innerHTML = '<span class="test-pill test-pill-ok">✓ delivered' +
(data.latency_ms ? ' · ' + data.latency_ms + ' ms' : '') +
(data.status_code ? ' · HTTP ' + data.status_code : '') + '</span>';
} else {
var msg = (data.error || 'unknown error');
target.innerHTML = '<span class="test-pill test-pill-fail">✗ failed · ' + msg + '</span>';
}
} catch(e) {
target.innerHTML = '<span class="test-pill test-pill-fail">✗ unexpected response</span>';
}
});
})();
</script>
{{end}}
+96
View File
@@ -0,0 +1,96 @@
{{define "alert_row"}}
{{$a := .Alert}}
{{$hostNames := .HostNames}}
{{$filter := .Filter}}
{{$status := alertStatus $a.ResolvedAt $a.AcknowledgedAt}}
{{/* derive query string for redirect-back after ack/resolve */}}
{{$qs := ""}}
{{if $filter.Status}}{{$qs = printf "status=%s" $filter.Status}}{{end}}
{{if $filter.Severity}}{{$qs = printf "%s&severity=%s" $qs $filter.Severity}}{{end}}
{{if $filter.HostID}}{{$qs = printf "%s&host_id=%s" $qs $filter.HostID}}{{end}}
{{if $filter.Search}}{{$qs = printf "%s&q=%s" $qs $filter.Search}}{{end}}
<div class="alert-row severity-{{$a.Severity}}{{if eq $status "resolved"}} resolved{{end}}"
{{if eq $status "acknowledged"}}style="background: color-mix(in oklch, var(--warn), transparent 96%);"{{end}}>
{{/* dot */}}
<div>
{{if eq $status "resolved"}}
<span class="dot dot-online"></span>
{{else if eq $a.Severity "critical"}}
<span class="dot dot-failed"></span>
{{else if eq $a.Severity "warning"}}
<span class="dot dot-degraded"></span>
{{else}}
<span class="dot dot-offline"></span>
{{end}}
</div>
{{/* severity + kind tag */}}
<div>
{{if eq $a.Severity "critical"}}
<span class="tag tag-critical">{{$a.Kind}}</span>
{{else if eq $a.Severity "warning"}}
<span class="tag tag-warn">{{$a.Kind}}</span>
{{else}}
<span class="tag tag-info">{{$a.Kind}}</span>
{{end}}
</div>
{{/* host */}}
<div class="mono {{if eq $status "resolved"}}text-ink-mid{{else}}text-ink{{end}}">
{{mapGet $hostNames $a.HostID}}
</div>
{{/* message */}}
<div class="{{if eq $status "resolved"}}text-ink-mute{{else}}text-ink{{end}}">
{{$a.Message}}
</div>
{{/* raised (created_at) */}}
<div class="mono {{if eq $status "resolved"}}text-ink-fade{{else}}text-ink-mid{{end}}">
{{relTime $a.CreatedAt}}
</div>
{{/* last seen */}}
<div class="mono {{if and (eq $status "open") (stillHappening $a.LastSeenAt)}}text-warn{{else if eq $status "resolved"}}text-ink-fade{{else}}text-ink-mid{{end}}">
{{if and (eq $status "open") (stillHappening $a.LastSeenAt)}}
still happening · {{relTime $a.LastSeenAt}}
{{else}}
{{relTime $a.LastSeenAt}}
{{end}}
</div>
{{/* actions */}}
<div style="display: flex; gap: 6px; justify-content: flex-end; align-items: center;">
{{if eq $status "open"}}
<form method="post" action="/alerts/{{$a.ID}}/acknowledge">
{{if $qs}}<input type="hidden" name="qs" value="{{$qs}}">{{end}}
<button type="submit" class="btn"
hx-post="/alerts/{{$a.ID}}/acknowledge{{if $qs}}?{{$qs}}{{end}}"
hx-swap="none">Acknowledge</button>
</form>
<form method="post" action="/alerts/{{$a.ID}}/resolve">
{{if $qs}}<input type="hidden" name="qs" value="{{$qs}}">{{end}}
<button type="submit" class="btn"
hx-post="/alerts/{{$a.ID}}/resolve{{if $qs}}?{{$qs}}{{end}}"
hx-swap="none">Resolve</button>
</form>
{{else if eq $status "acknowledged"}}
<span class="text-ink-fade" style="font-size: 11px;">
ack'd{{if $a.AcknowledgedBy}} by {{deref $a.AcknowledgedBy}}{{end}} · {{relTime $a.AcknowledgedAt}}
</span>
<form method="post" action="/alerts/{{$a.ID}}/resolve">
{{if $qs}}<input type="hidden" name="qs" value="{{$qs}}">{{end}}
<button type="submit" class="btn"
hx-post="/alerts/{{$a.ID}}/resolve{{if $qs}}?{{$qs}}{{end}}"
hx-swap="none">Resolve</button>
</form>
{{else}}
<span class="text-ink-fade" style="font-size: 11px;">resolved · {{relTime $a.ResolvedAt}}</span>
{{end}}
</div>
</div>
{{end}}
+13
View File
@@ -0,0 +1,13 @@
{{define "crit_banner"}}
{{if gt .CritOpenCount 0}}
<div class="crit-banner flex items-center justify-between mb-4 px-4 py-2.5 rounded-md text-[13px]"
style="border: 1px solid color-mix(in oklch, var(--bad), transparent 60%);
background: color-mix(in oklch, var(--bad), transparent 88%);">
<div class="flex items-center gap-3">
<span class="dot dot-critical"></span>
<span><span class="text-bad font-medium">{{.CritOpenCount}} critical alert{{if ne .CritOpenCount 1}}s{{end}}</span> open across the fleet</span>
</div>
<a href="/alerts?severity=critical&status=open" class="btn btn-danger">Review →</a>
</div>
{{end}}
{{end}}
+1 -1
View File
@@ -26,7 +26,7 @@
<nav class="flex items-end">
<a href="/" class="nav-tab {{if eq .Active "dashboard"}}active{{end}}">Dashboard</a>
<a href="/repos" class="nav-tab {{if eq .Active "repos"}}active{{end}}">Repos</a>
<a href="/alerts" class="nav-tab {{if eq .Active "alerts"}}active{{end}}">Alerts{{if .OpenAlerts}} <span class="mono ml-1.5 text-[11px] text-bad">{{.OpenAlerts}}</span>{{end}}</a>
<a href="/alerts" class="nav-tab {{if eq .Active "alerts"}}active{{end}}">Alerts{{if gt .OpenAlerts 0}} <span class="tag tag-critical mono ml-1">{{.OpenAlerts}}</span>{{end}}</a>
<a href="/audit" class="nav-tab {{if eq .Active "audit"}}active{{end}}">Audit</a>
<a href="/settings" class="nav-tab {{if eq .Active "settings"}}active{{end}}">Settings</a>
</nav>