Merge pull request 'Phase 3 — Alerts (P3-05/06/07)' (#7) from p3-alerts into main
Reviewed-on: #7
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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 := ¬ificationForm{
|
||||
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 = ¬ificationForm{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 := ¬ificationForm{
|
||||
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 = ¬ificationForm{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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -270,11 +270,13 @@ Sizes: **S** = under a day, **M** = 1–3 days, **L** = 3–7 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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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 <alerts@example.com>" />
|
||||
</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 /<topic> 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 <alerts@example.com>
|
||||
To: ops-overnight@example.com
|
||||
Subject: [restic-manager] [warning] alfa-01: backup_failed
|
||||
Message-ID: <01KQTABCDEFGHJ@restic-manager.example>
|
||||
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}}
|
||||
@@ -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}}
|
||||
@@ -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}}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user