mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-11-12 19:58:03 +00:00
New event observer.offline.own, open to every role and off by default. An owner gets the observer offline/online mails for observers bound to their own login; an admin with observer.offline too gets only the all-observers mails. Creating a login adds the event to the user's preferences. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
430 lines
14 KiB
Go
430 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log"
|
|
"net/url"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/meshcore-analyzer/mailer"
|
|
"github.com/meshcore-analyzer/users"
|
|
)
|
|
|
|
// notifier evaluates node notifications on a timer and mails the changes
|
|
// (docs/specs/2026-10-07-node-notifications-design.md). authService.notify
|
|
// is nil unless userManagement.notifications.enabled.
|
|
type notifier struct {
|
|
a *authService
|
|
src notifySource
|
|
now func() time.Time
|
|
// ingestStale is the last tick's verdict, so the pause and the resume
|
|
// are each logged once; ingestResumedAt is when the last stale period
|
|
// ended (the offline grace, see notifyInput). In memory only: a restart
|
|
// inside the grace loses it. Only the loop goroutine touches them.
|
|
ingestStale bool
|
|
ingestResumedAt time.Time
|
|
// externalErr is the last tick's reason the externalAlerts feed was not
|
|
// used ("" when it was), so each new reason is logged once.
|
|
externalErr string
|
|
// adaptive caches the learned offline thresholds (offlineWindows).
|
|
adaptive map[string]adaptiveEntry
|
|
}
|
|
|
|
func newNotifier(a *authService, src notifySource, now func() time.Time) *notifier {
|
|
return ¬ifier{a: a, src: src, now: now}
|
|
}
|
|
|
|
// notifySendTimeout bounds one mail send inside a tick.
|
|
const notifySendTimeout = 30 * time.Second
|
|
|
|
// notifyIngestStaleAfter: when the newest packet in the store is older,
|
|
// offline checks pause (an MQTT or ingestor outage would otherwise mail
|
|
// every watcher "offline" and later "back online").
|
|
const notifyIngestStaleAfter = 30 * time.Minute
|
|
|
|
// checkIngest reports whether ingest is stale at now and logs when that
|
|
// verdict changes.
|
|
func (n *notifier) checkIngest(now time.Time) bool {
|
|
newest := n.src.newestPacket()
|
|
stale := newest.IsZero() || now.Sub(newest) > notifyIngestStaleAfter
|
|
switch {
|
|
case stale && !n.ingestStale:
|
|
since := "(no packets)"
|
|
if !newest.IsZero() {
|
|
since = newest.UTC().Format(time.RFC3339)
|
|
}
|
|
log.Printf("[notify] ingest stale since %s; offline checks paused", since)
|
|
case !stale && n.ingestStale:
|
|
n.ingestResumedAt = now
|
|
log.Printf("[notify] ingest fresh again; offline checks resumed")
|
|
}
|
|
n.ingestStale = stale
|
|
return stale
|
|
}
|
|
|
|
// loop evaluates every interval until stop closes. The first evaluation
|
|
// runs one interval after startup. A tick in flight sees its context
|
|
// cancelled when stop closes, so a slow mail provider does not hold up
|
|
// the shutdown.
|
|
func (n *notifier) loop(stop <-chan struct{}, every time.Duration) {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
go func() {
|
|
select {
|
|
case <-stop:
|
|
cancel()
|
|
case <-ctx.Done():
|
|
}
|
|
}()
|
|
t := time.NewTicker(every)
|
|
defer t.Stop()
|
|
for {
|
|
select {
|
|
case <-stop:
|
|
return
|
|
case <-t.C:
|
|
n.safeTick(ctx)
|
|
}
|
|
}
|
|
}
|
|
|
|
// safeTick runs one evaluation; a panic is logged and the next tick runs.
|
|
func (n *notifier) safeTick(ctx context.Context) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Printf("[notify] evaluation panicked, the next one runs as planned: %v", r)
|
|
}
|
|
}()
|
|
n.tick(ctx)
|
|
}
|
|
|
|
// tick reads all preferences, watches and states (one query each), the
|
|
// accounts and the analyzer snapshot they need, evaluates, deletes the
|
|
// dropped states and writes the new ones, and then mails each user's
|
|
// changes. Users without a preferences row and users with notifications
|
|
// off are evaluated too (the evaluator gives the first the defaults and
|
|
// keeps the second's states current without changes). A failed read or
|
|
// state write ends the tick without mail; the next tick starts over. A
|
|
// tick that evaluates logs one line with its counts, its duration and how
|
|
// long LastHeardMap held the packet store's read lock.
|
|
func (n *notifier) tick(ctx context.Context) {
|
|
if !n.src.ready() {
|
|
return
|
|
}
|
|
started := time.Now()
|
|
now := n.now()
|
|
stale := n.checkIngest(now)
|
|
st := n.a.st
|
|
prefs, err := st.AllNotifyPrefs()
|
|
if err != nil {
|
|
log.Printf("[notify] read preferences: %v", err)
|
|
return
|
|
}
|
|
stored, err := st.AllWatches()
|
|
if err != nil {
|
|
log.Printf("[notify] read watches: %v", err)
|
|
return
|
|
}
|
|
if len(prefs) == 0 && len(stored) == 0 {
|
|
return
|
|
}
|
|
states, err := st.AllNotifyStates()
|
|
if err != nil {
|
|
log.Printf("[notify] read states: %v", err)
|
|
return
|
|
}
|
|
// The analyzer snapshot is keyed by lowercase pubkey; normalise the
|
|
// watches once so every lookup and state subject uses that form.
|
|
watches := make([]users.NotifyWatch, 0, len(stored))
|
|
watchSeen := make(map[users.NotifyKey]bool, len(stored))
|
|
seen := map[string]bool{}
|
|
var pubkeys []string
|
|
for _, w := range stored {
|
|
w.Pubkey = strings.ToLower(w.Pubkey)
|
|
k := users.NotifyKey{UserID: w.UserID, Subject: w.Pubkey}
|
|
if watchSeen[k] {
|
|
continue
|
|
}
|
|
watchSeen[k] = true
|
|
watches = append(watches, w)
|
|
if !seen[w.Pubkey] {
|
|
seen[w.Pubkey] = true
|
|
pubkeys = append(pubkeys, w.Pubkey)
|
|
}
|
|
}
|
|
byUser := make(map[int64]users.NotifyPrefs, len(prefs))
|
|
var ids []int64
|
|
for _, p := range prefs {
|
|
byUser[p.UserID] = p
|
|
ids = append(ids, p.UserID)
|
|
}
|
|
noPrefs := map[int64]bool{}
|
|
for _, w := range watches {
|
|
if _, ok := byUser[w.UserID]; !ok && !noPrefs[w.UserID] {
|
|
noPrefs[w.UserID] = true
|
|
ids = append(ids, w.UserID)
|
|
}
|
|
}
|
|
accounts, err := st.UsersByID(ids)
|
|
if err != nil {
|
|
log.Printf("[notify] read accounts: %v", err)
|
|
return
|
|
}
|
|
wantExternal := false
|
|
if n.a.set.notify.external.url != "" {
|
|
for _, p := range prefs {
|
|
wantExternal = wantExternal || p.Has(users.NotifyNodeExternal)
|
|
}
|
|
}
|
|
wantForeign, wantObservers := false, false
|
|
for _, p := range prefs {
|
|
if accounts[p.UserID].Role != users.RoleAdmin {
|
|
continue
|
|
}
|
|
wantForeign = wantForeign || p.Has(users.NotifyForeignNew)
|
|
wantObservers = wantObservers || p.Has(users.NotifyObserverOffline)
|
|
}
|
|
nodes, err := n.src.nodes(pubkeys, wantForeign)
|
|
if err != nil {
|
|
log.Printf("[notify] read nodes: %v", err)
|
|
return
|
|
}
|
|
var owners map[string]int64
|
|
if n.a.obsIdx != nil {
|
|
for _, p := range prefs {
|
|
if p.Has(users.NotifyObserverOfflineOwn) {
|
|
wantObservers = true
|
|
owners = n.a.obsIdx.ownersSnapshot()
|
|
break
|
|
}
|
|
}
|
|
}
|
|
var observers []notifyObserver
|
|
if wantObservers {
|
|
if observers, err = n.src.observers(); err != nil {
|
|
log.Printf("[notify] read observers: %v", err)
|
|
return
|
|
}
|
|
}
|
|
var external *externalFeed
|
|
if wantExternal {
|
|
external = n.readExternal(now)
|
|
}
|
|
heard, lock := n.src.lastHeard(pubkeys)
|
|
var infra []string
|
|
for _, pk := range pubkeys {
|
|
if r := strings.ToLower(nodes[pk].Role); r == "repeater" || r == "room" {
|
|
infra = append(infra, pk)
|
|
}
|
|
}
|
|
res := evaluateNotifications(notifyInput{Now: now, Health: n.src.health(), LowMv: n.src.lowBatteryMv(),
|
|
Accounts: accounts, Prefs: prefs, Watches: watches, States: states, Nodes: nodes,
|
|
Heard: heard, SharedSeen: n.src.sharedPrefixSeen(infra), OfflineAfter: n.offlineWindows(pubkeys, now), NewestPacket: n.src.newestPacket(), Observers: observers, ObserverOwners: owners, IngestStale: stale, IngestResumedAt: n.ingestResumedAt, External: external})
|
|
if err := st.DeleteNotifyStates(res.Drop); err != nil {
|
|
log.Printf("[notify] delete dropped states, nothing mailed this time: %v", err)
|
|
return
|
|
}
|
|
if len(res.States) > 0 {
|
|
if err := st.WriteNotifyStates(res.States); err != nil {
|
|
log.Printf("[notify] write states, nothing mailed this time: %v", err)
|
|
return
|
|
}
|
|
}
|
|
mails := n.deliver(ctx, now, res.Changes, accounts, byUser)
|
|
changes := 0
|
|
for _, list := range res.Changes {
|
|
changes += len(list)
|
|
}
|
|
log.Printf("[notify] tick: users=%d changes=%d mails=%d took=%.2fms lock=%.2fms",
|
|
len(accounts), changes, mails, msFloat(time.Since(started)), msFloat(lock))
|
|
}
|
|
|
|
// readExternal fetches and validates the externalAlerts feed. It returns
|
|
// nil when the feed cannot be used (fetch failed, malformed, too old), so
|
|
// no node.external state changes; a dead feed never mails "resolved". The
|
|
// reason is logged when it changes, and once when the feed is usable again.
|
|
func (n *notifier) readExternal(now time.Time) *externalFeed {
|
|
body, err := n.src.externalFeed()
|
|
var f *externalFeed
|
|
if err == nil {
|
|
f, err = parseExternalFeed(body, now, n.a.set.notify.external.maxAge)
|
|
}
|
|
why := ""
|
|
if err != nil {
|
|
why = err.Error()
|
|
}
|
|
switch {
|
|
case why != "" && why != n.externalErr:
|
|
log.Printf("[notify] external alerts feed not used, node.external unchanged: %s", why)
|
|
case why == "" && n.externalErr != "":
|
|
log.Printf("[notify] external alerts feed usable again")
|
|
}
|
|
n.externalErr = why
|
|
if f != nil && f.Skipped > 0 {
|
|
log.Printf("[notify] external alerts feed: %d malformed alert(s) skipped", f.Skipped)
|
|
}
|
|
return f
|
|
}
|
|
|
|
func msFloat(d time.Duration) float64 { return float64(d.Microseconds()) / 1000 }
|
|
|
|
// notifySkipReason says why a user's changes are not mailed; "" means send.
|
|
// Skipped changes are already stored and are never mailed later.
|
|
func notifySkipReason(u users.User, p users.NotifyPrefs, userMails, totalMails int, ns notifySettings) string {
|
|
switch {
|
|
case u.Status != users.StatusActive:
|
|
return "account not active"
|
|
case u.EmailBouncing:
|
|
return "address bounces"
|
|
case !p.Enabled:
|
|
return "notifications off"
|
|
case userMails >= ns.perUserPerDay:
|
|
return "daily limit for this user reached"
|
|
case totalMails >= ns.maxMailsPerDay:
|
|
return "daily limit for this instance reached"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// deliver sends one mail per user with changes, within the limits counted
|
|
// from mail_log over the last 24 hours (this tick's mails included). A user
|
|
// with changes but no preferences row was evaluated with the defaults; the
|
|
// row (and so the unsubscribe token) is created before the first mail.
|
|
// Once ctx is cancelled (shutdown) the remaining users are not mailed;
|
|
// their changes are already stored, like any other skipped change. It
|
|
// returns the number of mails sent.
|
|
func (n *notifier) deliver(ctx context.Context, now time.Time, changes map[int64][]notifyChange,
|
|
accounts map[int64]users.User, prefs map[int64]users.NotifyPrefs) int {
|
|
if len(changes) == 0 {
|
|
return 0
|
|
}
|
|
total, perUser, err := n.a.st.NotifyMailCounts(now.Add(-24 * time.Hour))
|
|
if err != nil {
|
|
log.Printf("[notify] count mails, nothing mailed this time: %v", err)
|
|
return 0
|
|
}
|
|
sent := 0
|
|
ids := make([]int64, 0, len(changes))
|
|
for uid := range changes {
|
|
ids = append(ids, uid)
|
|
}
|
|
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
|
for i, uid := range ids {
|
|
if ctx.Err() != nil {
|
|
log.Printf("[notify] shutting down: %d user(s) with changes not mailed", len(ids)-i)
|
|
return sent
|
|
}
|
|
u := accounts[uid]
|
|
p, ok := prefs[uid]
|
|
if !ok {
|
|
p = users.DefaultNotifyPrefs(uid)
|
|
}
|
|
if why := notifySkipReason(u, p, perUser[uid], total, n.a.set.notify); why != "" {
|
|
log.Printf("[notify] user #%d: %d change(s) not mailed: %s", uid, len(changes[uid]), why)
|
|
continue
|
|
}
|
|
if !ok {
|
|
if p, err = n.a.st.NotifyPrefsFor(uid); err != nil {
|
|
log.Printf("[notify] user #%d: create preferences, %d change(s) not mailed: %v", uid, len(changes[uid]), err)
|
|
continue
|
|
}
|
|
}
|
|
sendCtx, cancel := context.WithTimeout(ctx, notifySendTimeout)
|
|
err := n.a.sendMail(sendCtx, &u, users.NotifyMailPurpose, n.a.notifyMail(u, p, changes[uid]))
|
|
cancel()
|
|
if err != nil {
|
|
continue // logged by sendMail; the changes stay recorded
|
|
}
|
|
total++
|
|
perUser[uid]++
|
|
sent++
|
|
}
|
|
return sent
|
|
}
|
|
|
|
// notifyMail is the one mail for all of a user's changes in one evaluation.
|
|
func (a *authService) notifyMail(u users.User, p users.NotifyPrefs, changes []notifyChange) mailer.Message {
|
|
base := a.set.baseURL.String()
|
|
lines := make([]mailLine, 0, len(changes))
|
|
for _, c := range changes {
|
|
lines = append(lines, mailLine{text: notifyChangeText(c, a.set.notify.external.label), url: notifySubjectURL(base, c)})
|
|
}
|
|
subject := fmt.Sprintf("%d changes on your watched nodes", len(changes))
|
|
if len(changes) == 1 {
|
|
subject = "1 change on your watched nodes"
|
|
}
|
|
msg := a.render(u.Email, u.DisplayName, "notify", mailContent{
|
|
subject: subject, greeting: "Hello " + u.DisplayName + ",",
|
|
paragraphs: []string{"Changes since the last check:"},
|
|
lines: lines,
|
|
actionLabel: "Manage notifications", actionURL: base + "/#/account?section=notifications",
|
|
footer: &mailLine{text: "You get this mail because you asked " + a.set.baseURL.Host +
|
|
" to watch these nodes. Turn all notification mails off with one click:", url: a.link("unsubscribe", p.UnsubToken)},
|
|
})
|
|
msg.Headers = map[string]string{
|
|
"List-Unsubscribe": "<" + base + "/api/notifications/unsubscribe?token=" + url.QueryEscape(p.UnsubToken) + ">",
|
|
"List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
}
|
|
return msg
|
|
}
|
|
|
|
// notifyChangeText is one line of the mail: name, what happened, when (UTC).
|
|
// label names the externalAlerts source.
|
|
func notifyChangeText(c notifyChange, label string) string {
|
|
bad := c.To == users.NotifyBad
|
|
var what string
|
|
switch c.Event {
|
|
case users.NotifyNodeOffline:
|
|
what = "back online"
|
|
if bad {
|
|
what = "offline"
|
|
if !c.SharedSeen.IsZero() {
|
|
// #2260: the UI shows such a node as "uncertain".
|
|
what += " (nothing that can only be this node; its hop prefix, shared with other repeaters, was last in a path " +
|
|
c.SharedSeen.UTC().Format("2006-01-02 15:04 UTC") + ", possibly another node)"
|
|
}
|
|
}
|
|
case users.NotifyNodeBattery:
|
|
what = "battery recovered"
|
|
if bad {
|
|
what = "battery low"
|
|
}
|
|
if c.BatteryMv != nil {
|
|
what += fmt.Sprintf(" (%d mV)", *c.BatteryMv)
|
|
}
|
|
case users.NotifyNodeExternal:
|
|
_, key, _ := strings.Cut(c.Subject, "/")
|
|
what = label + " resolved (" + key + ")"
|
|
if bad {
|
|
what = label + ": " + c.Text
|
|
}
|
|
case users.NotifyForeignNew:
|
|
what = "new foreign node"
|
|
case users.NotifyObserverOffline, users.NotifyObserverOfflineOwn:
|
|
what = "observer back online"
|
|
if bad {
|
|
what = "observer offline"
|
|
}
|
|
}
|
|
return c.Name + ": " + what + ", " + c.At.UTC().Format("2006-01-02 15:04 UTC")
|
|
}
|
|
|
|
// notifySubjectURL links a change to its node or observer page, or an
|
|
// external alert to the link the feed gave it.
|
|
func notifySubjectURL(base string, c notifyChange) string {
|
|
switch c.Event {
|
|
case users.NotifyObserverOffline, users.NotifyObserverOfflineOwn:
|
|
return base + "/#/observers/" + url.PathEscape(c.Subject)
|
|
case users.NotifyNodeExternal:
|
|
if c.URL != "" {
|
|
return c.URL
|
|
}
|
|
pk, _, _ := strings.Cut(c.Subject, "/")
|
|
return base + "/#/nodes/" + url.PathEscape(pk)
|
|
}
|
|
return base + "/#/nodes/" + url.PathEscape(c.Subject)
|
|
}
|