Added notification manager

This commit is contained in:
Magnus Åhall 2024-05-05 10:10:04 +02:00
parent 317c233255
commit 49af9dc33c
6 changed files with 318 additions and 51 deletions

98
main.go
View File

@ -4,7 +4,10 @@ import (
// External // External
ws "git.gibonuddevalla.se/go/webservice" ws "git.gibonuddevalla.se/go/webservice"
"git.gibonuddevalla.se/go/webservice/session" "git.gibonuddevalla.se/go/webservice/session"
we "git.gibonuddevalla.se/go/wrappederror" werr "git.gibonuddevalla.se/go/wrappederror"
// Internal
"smon/notification"
// Standard // Standard
"embed" "embed"
@ -33,6 +36,7 @@ var (
logFile *os.File logFile *os.File
parsedTemplates map[string]*template.Template parsedTemplates map[string]*template.Template
componentFilenames []string componentFilenames []string
notificationManager notification.Manager
//go:embed sql //go:embed sql
sqlFS embed.FS sqlFS embed.FS
@ -50,7 +54,7 @@ func init() { // {{{
confDir, err := os.UserConfigDir() confDir, err := os.UserConfigDir()
if err != nil { if err != nil {
logger.Error("application", "error", we.Wrap(err)) logger.Error("application", "error", werr.Wrap(err))
} }
cfgPath := path.Join(confDir, "smon.yaml") cfgPath := path.Join(confDir, "smon.yaml")
flag.StringVar(&flagConfigFile, "config", cfgPath, "Path and filename of the YAML configuration file") flag.StringVar(&flagConfigFile, "config", cfgPath, "Path and filename of the YAML configuration file")
@ -68,8 +72,8 @@ func init() { // {{{
func main() { // {{{ func main() { // {{{
var err error var err error
we.Init() werr.Init()
we.SetLogCallback(logHandler) werr.SetLogCallback(logHandler)
service, err = ws.New(flagConfigFile, VERSION, logger) service, err = ws.New(flagConfigFile, VERSION, logger)
if err != nil { if err != nil {
@ -96,6 +100,12 @@ func main() { // {{{
return return
} }
err = InitNotificationManager()
if err != nil {
err = werr.Wrap(err).Log()
logger.Error("notification", "error", err)
}
service.Register("/", false, false, staticHandler) service.Register("/", false, false, staticHandler)
service.Register("/area/new/{name}", false, false, areaNew) service.Register("/area/new/{name}", false, false, areaNew)
@ -124,7 +134,7 @@ func main() { // {{{
err = service.Start() err = service.Start()
if err != nil { if err != nil {
logger.Error("webserver", "error", we.Wrap(err)) logger.Error("webserver", "error", werr.Wrap(err))
os.Exit(1) os.Exit(1)
} }
@ -139,7 +149,7 @@ func sqlProvider(dbname string, version int) (sql []byte, found bool) { // {{{
found = true found = true
return return
} // }}} } // }}}
func logHandler(err we.Error) { // {{{ func logHandler(err werr.Error) { // {{{
j, _ := json.Marshal(err) j, _ := json.Marshal(err)
logFile.Write(j) logFile.Write(j)
logFile.Write([]byte("\n")) logFile.Write([]byte("\n"))
@ -175,7 +185,7 @@ func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { /
err := DatapointAdd(dpoint, value) err := DatapointAdd(dpoint, value)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -188,7 +198,7 @@ func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { /
var out any var out any
out, err = trigger.Run() out, err = trigger.Run()
if err != nil { if err != nil {
err = we.Wrap(err).Log() err = werr.Wrap(err).Log()
logger.Error("entry", "error", err) logger.Error("entry", "error", err)
} }
@ -200,13 +210,13 @@ func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { /
if v { if v {
err = ProblemStart(trigger) err = ProblemStart(trigger)
if err != nil { if err != nil {
err = we.Wrap(err).Log() err = werr.Wrap(err).Log()
logger.Error("entry", "error", err) logger.Error("entry", "error", err)
} }
} else { } else {
err = ProblemClose(trigger) err = ProblemClose(trigger)
if err != nil { if err != nil {
err = we.Wrap(err).Log() err = werr.Wrap(err).Log()
logger.Error("entry", "error", err) logger.Error("entry", "error", err)
} }
} }
@ -214,7 +224,7 @@ func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { /
default: default:
err := fmt.Errorf(`Expression for trigger %s not returning bool (%T)`, trigger.Name, v) err := fmt.Errorf(`Expression for trigger %s not returning bool (%T)`, trigger.Name, v)
logger.Info("entry", "error", err) logger.Info("entry", "error", err)
we.Wrap(err).WithData(v).Log() werr.Wrap(err).WithData(v).Log()
} }
} }
@ -268,7 +278,7 @@ func getPage(layout, page string) (tmpl *template.Template, err error) { // {{{
tmpl, err = template.New("main.gotmpl").Funcs(funcMap).ParseFS(viewFS, filenames...) tmpl, err = template.New("main.gotmpl").Funcs(funcMap).ParseFS(viewFS, filenames...)
} }
if err != nil { if err != nil {
err = we.Wrap(err).Log() err = werr.Wrap(err).Log()
return return
} }
@ -288,7 +298,7 @@ func areaNew(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
name := r.PathValue("name") name := r.PathValue("name")
err := AreaCreate(name) err := AreaCreate(name)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -300,14 +310,14 @@ func areaRename(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
name := r.PathValue("name") name := r.PathValue("name")
err = AreaRename(id, name) err = AreaRename(id, name)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -320,14 +330,14 @@ func sectionNew(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
idStr := r.PathValue("areaID") idStr := r.PathValue("areaID")
areaID, err := strconv.Atoi(idStr) areaID, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
name := r.PathValue("name") name := r.PathValue("name")
err = SectionCreate(areaID, name) err = SectionCreate(areaID, name)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -339,14 +349,14 @@ func sectionRename(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
name := r.PathValue("name") name := r.PathValue("name")
err = SectionRename(id, name) err = SectionRename(id, name)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -363,7 +373,7 @@ func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
problems, err := ProblemsRetrieve() problems, err := ProblemsRetrieve()
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -377,13 +387,13 @@ func pageProblemAcknowledge(w http.ResponseWriter, r *http.Request, _ *session.T
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
err = ProblemAcknowledge(id, true) err = ProblemAcknowledge(id, true)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -396,13 +406,13 @@ func pageProblemUnacknowledge(w http.ResponseWriter, r *http.Request, _ *session
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
err = ProblemAcknowledge(id, false) err = ProblemAcknowledge(id, false)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -420,7 +430,7 @@ func pageDatapoints(w http.ResponseWriter, r *http.Request, _ *session.T) { // {
datapoints, err := DatapointsRetrieve() datapoints, err := DatapointsRetrieve()
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -441,7 +451,7 @@ func pageDatapointEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { /
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -452,7 +462,7 @@ func pageDatapointEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { /
} else { } else {
datapoint, err = DatapointRetrieve(id, "") datapoint, err = DatapointRetrieve(id, "")
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
} }
@ -475,7 +485,7 @@ func pageDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) {
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -485,7 +495,7 @@ func pageDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) {
dp.Datatype = DatapointType(r.FormValue("datatype")) dp.Datatype = DatapointType(r.FormValue("datatype"))
err = dp.Update() err = dp.Update()
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -496,13 +506,13 @@ func pageDatapointDelete(w http.ResponseWriter, r *http.Request, _ *session.T) {
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
err = DatapointDelete(id) err = DatapointDelete(id)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -513,7 +523,7 @@ func pageDatapointDelete(w http.ResponseWriter, r *http.Request, _ *session.T) {
func pageTriggers(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ func pageTriggers(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
areas, err := TriggersRetrieve() areas, err := TriggersRetrieve()
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -535,7 +545,7 @@ func pageTriggerEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { //
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -546,7 +556,7 @@ func pageTriggerEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { //
if id > 0 { if id > 0 {
trigger, err = TriggerRetrieve(id) trigger, err = TriggerRetrieve(id)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
} else { } else {
@ -555,7 +565,7 @@ func pageTriggerEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { //
if sectionIDStr != "" { if sectionIDStr != "" {
trigger.SectionID, err = strconv.Atoi(sectionIDStr) trigger.SectionID, err = strconv.Atoi(sectionIDStr)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
} }
@ -565,7 +575,7 @@ func pageTriggerEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { //
for _, dpname := range trigger.Datapoints { for _, dpname := range trigger.Datapoints {
dp, err := DatapointRetrieve(0, dpname) dp, err := DatapointRetrieve(0, dpname)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
dp.LastDatapointValue.TemplateValue = dp.LastDatapointValue.Value() dp.LastDatapointValue.TemplateValue = dp.LastDatapointValue.Value()
@ -590,7 +600,7 @@ func pageTriggerUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { /
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -598,13 +608,13 @@ func pageTriggerUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { /
if id > 0 { if id > 0 {
trigger, err = TriggerRetrieve(id) trigger, err = TriggerRetrieve(id)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
} else { } else {
trigger.SectionID, err = strconv.Atoi(r.FormValue("sectionID")) trigger.SectionID, err = strconv.Atoi(r.FormValue("sectionID"))
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
} }
@ -614,7 +624,7 @@ func pageTriggerUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { /
trigger.Datapoints = r.Form["datapoints[]"] trigger.Datapoints = r.Form["datapoints[]"]
err = trigger.Update() err = trigger.Update()
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -625,14 +635,14 @@ func pageTriggerRun(w http.ResponseWriter, r *http.Request, _ *session.T) { // {
idStr := r.PathValue("id") idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr) id, err := strconv.Atoi(idStr)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
var trigger Trigger var trigger Trigger
trigger, err = TriggerRetrieve(id) trigger, err = TriggerRetrieve(id)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }
@ -647,7 +657,7 @@ func pageTriggerRun(w http.ResponseWriter, r *http.Request, _ *session.T) { // {
} }
resp.Output, err = trigger.Run() resp.Output, err = trigger.Run()
if err != nil { if err != nil {
we.Wrap(err).Log() werr.Wrap(err).Log()
httpError(w, err) httpError(w, err)
return return
} }
@ -660,7 +670,7 @@ func pageTriggerRun(w http.ResponseWriter, r *http.Request, _ *session.T) { // {
func pageConfiguration(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ func pageConfiguration(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
areas, err := AreaRetrieve() areas, err := AreaRetrieve()
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
} }

23
notification/factory.go Normal file
View File

@ -0,0 +1,23 @@
package notification
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard
"log/slog"
)
func ServiceFactory(t string, config []byte, prio int, ackURL string, logger *slog.Logger) (Service, error) {
switch t {
case "NTFY":
ntfy, err := NewNTFY(config, prio, ackURL)
if err != nil {
err = werr.Wrap(err).WithData(config)
return nil, err
}
return ntfy, nil
}
return nil, werr.New("Unknown notification service, '%s'", t).WithCode("002-0000")
}

83
notification/ntfy.go Normal file
View File

@ -0,0 +1,83 @@
package notification
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard
"bytes"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
)
type NTFY struct {
URL string
Prio int
AcknowledgeURL string
logger *slog.Logger
}
func NewNTFY(config []byte, prio int, ackURL string) (instance *NTFY, err error) {
instance = new(NTFY)
err = json.Unmarshal(config, &instance)
if err != nil {
err = werr.Wrap(err).WithCode("002-0001").WithData(config)
return
}
instance.Prio = prio
instance.AcknowledgeURL = ackURL
return instance, nil
}
func (ntfy *NTFY) SetLogger(l *slog.Logger) {
ntfy.logger = l
}
func (ntfy *NTFY) GetType() string {
return "NTFY"
}
func (ntfy *NTFY) GetPrio() int {
return ntfy.Prio
}
func (ntfy NTFY) Send(uuid string, msg []byte) (err error) {
var req *http.Request
var res *http.Response
req, err = http.NewRequest("POST", ntfy.URL, bytes.NewReader(msg))
if err != nil {
err = werr.Wrap(err).WithCode("002-0002").WithData(ntfy.URL)
return
}
ackURL := fmt.Sprintf("http, OK, %s/notification/ack?uuid=%s", ntfy.AcknowledgeURL, uuid)
req.Header.Add("X-Actions", ackURL)
req.Header.Add("X-Priority", "3") // XXX: should be 5
req.Header.Add("X-Tags", "calendar")
res, err = http.DefaultClient.Do(req)
if err != nil {
err = werr.Wrap(err).WithCode("002-0003")
return
}
body, _ := io.ReadAll(res.Body)
if res.StatusCode != 200 {
err = werr.New("Invalid NTFY response").WithCode("002-0004").WithData(body)
return
}
ntfyResp := struct {
ID string
}{}
err = json.Unmarshal(body, &ntfyResp)
if err != nil {
err = werr.Wrap(err).WithCode("002-0005").WithData(body)
return
}
return
}

61
notification/pkg.go Normal file
View File

@ -0,0 +1,61 @@
package notification
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard
"log/slog"
"slices"
)
type Service interface {
SetLogger(*slog.Logger)
GetPrio() int
GetType() string
Send(string, []byte) error
}
type Manager struct {
services []Service
logger *slog.Logger
}
func NewManager(logger *slog.Logger) (nm Manager) {
nm.services = []Service{}
nm.logger = logger
return
}
func (nm *Manager) AddService(service Service) {
nm.services = append(nm.services, service)
slices.SortFunc(nm.services, func(a, b Service) int {
if a.GetPrio() < b.GetPrio() {
return -1
}
if a.GetPrio() > b.GetPrio() {
return 1
}
return 0
})
}
func (nm *Manager) Send(uuid string, msg []byte) (err error) {
for _, service := range nm.services {
nm.logger.Info("notification", "service", service.GetType(), "prio", service.GetPrio())
if err = service.Send(uuid, msg); err == nil {
break
} else {
data := struct {
UUID string
Msg []byte
}{
uuid,
msg,
}
werr.Wrap(err).WithData(data).Log()
}
}
return
}

80
notification_manager.go Normal file
View File

@ -0,0 +1,80 @@
package main
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
// Internal
"smon/notification"
// Standard
"database/sql"
"encoding/json"
)
type DbNotificationService struct {
ID int
Service string
Configuration string
Prio int
}
func InitNotificationManager() (err error) { // {{{
var dbServices []DbNotificationService
var row *sql.Row
row = service.Db.Conn.QueryRow(`
WITH services AS (
SELECT
id,
prio,
service,
configuration::varchar
FROM notification n
ORDER BY
prio ASC
)
SELECT COALESCE(jsonb_agg(s.*), '[]')
FROM services s
`,
)
var dbData []byte
err = row.Scan(&dbData)
if err != nil {
err = werr.Wrap(err).WithCode("002-0006")
return
}
err = json.Unmarshal(dbData, &dbServices)
if err != nil {
err = werr.Wrap(err).WithCode("002-0007")
return
}
notificationManager = notification.NewManager(logger)
var service notification.Service
for _, dbService := range dbServices {
service, err = notification.ServiceFactory(
dbService.Service,
[]byte(dbService.Configuration),
dbService.Prio,
"blah",
//config.Application.NotificationBaseURL,
logger,
)
if err != nil {
err = werr.Wrap(err).WithData(dbService.Service)
}
notificationManager.AddService(service)
}
return
} // }}}
func AcknowledgeNotification(uuid string) (err error) { // {{{
_, err = service.Db.Conn.Exec(`UPDATE schedule SET acknowledged=true WHERE schedule_uuid=$1`, uuid)
if err != nil {
err = werr.Wrap(err).WithData(uuid)
}
return
} // }}}

10
sql/00011.sql Normal file
View File

@ -0,0 +1,10 @@
CREATE TYPE notification_type AS ENUM('NTFY', 'PUSHOVER', 'HTTP', 'EMAIL');
CREATE TABLE public.notification (
id serial4 NOT NULL,
service notification_type DEFAULT 'NTFY' NOT NULL,
"configuration" jsonb DEFAULT '{}'::jsonb NOT NULL,
prio int4 DEFAULT 0 NOT NULL,
CONSTRAINT notification_pk PRIMARY KEY (id),
CONSTRAINT notification_unique UNIQUE (prio)
);