From c7ad2aa1b644ba53ee782dd5d2fccd4b8cb53cfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Fri, 28 Jun 2024 15:28:52 +0200 Subject: [PATCH] Add/edit notifications --- main.go | 113 ++++++++++++++++++++- notification/factory.go | 28 +++++ notification/ntfy.go | 63 ++++++++++++ notification/pkg.go | 26 ++++- notification/script.go | 61 +++++++++++ notification_manager.go | 57 ++++++++++- static/css/default_light/configuration.css | 17 ++++ static/css/default_light/default_light.css | 3 +- static/css/default_light/gruvbox.css | 6 ++ static/css/gruvbox/configuration.css | 17 ++++ static/css/gruvbox/default_light.css | 3 +- static/css/gruvbox/gruvbox.css | 6 ++ static/less/configuration.less | 20 ++++ static/less/default_light.less | 2 +- static/less/gruvbox.less | 7 ++ views/layouts/notification.gotmpl | 19 ++++ views/pages/configuration.gotmpl | 26 +++++ views/pages/notification/ntfy.gotmpl | 34 +++++++ views/pages/notification/script.gotmpl | 34 +++++++ 19 files changed, 531 insertions(+), 11 deletions(-) create mode 100644 views/layouts/notification.gotmpl create mode 100644 views/pages/notification/ntfy.gotmpl create mode 100644 views/pages/notification/script.gotmpl diff --git a/main.go b/main.go index 7424767..6e368a1 100644 --- a/main.go +++ b/main.go @@ -25,6 +25,7 @@ import ( "slices" "sort" "strconv" + "strings" "time" ) @@ -152,6 +153,8 @@ func main() { // {{{ service.Register("/configuration", false, false, pageConfiguration) service.Register("/configuration/theme", false, false, actionConfigurationTheme) service.Register("/configuration/timezone", false, false, actionConfigurationTimezone) + service.Register("/configuration/notification", false, false, pageConfigurationNotification) + service.Register("/configuration/notification/update/{prio}", false, false, actionConfigurationNotificationUpdate) service.Register("/entry/{datapoint}", false, false, actionEntryDatapoint) go nodataLoop() @@ -379,9 +382,9 @@ func getPage(layout, page string) (tmpl *template.Template, err error) { // {{{ filenames = append(filenames, componentFilenames...) logger.Info("template", "op", "parse", "layout", layout, "page", page, "filenames", filenames) if flagDev { - tmpl, err = template.New("main.gotmpl").Funcs(funcMap).ParseFS(os.DirFS("."), filenames...) + tmpl, err = template.New(layout+".gotmpl").Funcs(funcMap).ParseFS(os.DirFS("."), filenames...) } else { - tmpl, err = template.New("main.gotmpl").Funcs(funcMap).ParseFS(viewFS, filenames...) + tmpl, err = template.New(layout+".gotmpl").Funcs(funcMap).ParseFS(viewFS, filenames...) } if err != nil { err = werr.Wrap(err).Log() @@ -1036,7 +1039,9 @@ func pageConfiguration(w http.ResponseWriter, r *http.Request, _ *session.T) { / PAGE: "configuration", CONFIG: smonConfig.Settings, Data: map[string]any{ - "Areas": areas, + "Areas": areas, + "NotificationServices": notificationManager.Services(), + "AvailableServices": notification.AvailableServices(), }, } @@ -1071,3 +1076,105 @@ func actionConfigurationTimezone(w http.ResponseWriter, r *http.Request, _ *sess w.Header().Add("Location", "/configuration") w.WriteHeader(302) } // }}} +func pageConfigurationNotification(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ + // This function is either receiving a type when creating a new service, + // or a prio when editing an existing. + notificationType := r.URL.Query().Get("type") + prioStr := r.URL.Query().Get("prio") + + var service notification.Service + if notificationType != "" { + // Create a new instance of the selected notification type. + service = notification.GetInstance(notificationType) + } else { + // Find the existing service for editing. + prio, err := strconv.Atoi(prioStr) + if err != nil { + httpError(w, werr.Wrap(err).Log()) + return + } + service = *notificationManager.GetService(prio) + } + + if service == nil { + data := struct { + typ string + prio string + }{notificationType, prioStr} + err := fmt.Errorf("Invalid service") + pageError(w, "/configuration", werr.Wrap(err).WithData(data).Log()) + return + } + + // Make it easier for the user by initiating the prio field + // for new notifications to the highest prio + 1. + if notificationType != "" { + prio := 0 + for _, svc := range notificationManager.Services() { + prio = max(prio, svc.GetPrio()+1) + } + service.SetPrio(prio) + } + + page := Page{ + LAYOUT: "notification", + PAGE: "notification/" + strings.ToLower(service.GetType()), + CONFIG: smonConfig.Settings, + Data: map[string]any{ + "Service": service, + }, + } + + page.Render(w, r) +} // }}} +func actionConfigurationNotificationUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ + prioStr := r.PathValue("prio") + prio, err := strconv.Atoi(prioStr) + if err != nil { + pageError(w, "/configuration", werr.Wrap(err).Log()) + return + } + + // prio -1 means a new service, not existing in database. + var svc *notification.Service + if prio == -1 { + emptyService := notification.GetInstance(r.PostFormValue("type")) + svc = &emptyService + } else { + svc = notificationManager.GetService(prio) + if svc == nil { + pageError(w, "/configuration", werr.New("Service with prio %d not found", prio).Log()) + return + } + } + + // The service is given all data to give it a chance to + // validate and throwing errors. + err = r.ParseForm() + if err != nil { + pageError(w, "/configuration", werr.Wrap(err).Log()) + return + } + + err = (*svc).Update(r.PostForm) + if err != nil { + pageError(w, "/configuration", werr.Wrap(err).Log()) + return + } + + var created bool + created, err = UpdateNotificationService(*svc) + if err != nil { + pageError(w, "/configuration", werr.Wrap(err).Log()) + return + } + + (*svc).Commit() + + if created { + notificationManager.AddService(*svc) + } + + w.Header().Add("Location", "/configuration") + w.WriteHeader(302) +} // }}} diff --git a/notification/factory.go b/notification/factory.go index 90c5cfe..4c7e25f 100644 --- a/notification/factory.go +++ b/notification/factory.go @@ -6,6 +6,12 @@ import ( // Standard "log/slog" + "slices" + "strings" +) + +var ( + allServices []Service ) func ServiceFactory(t string, config []byte, prio int, ackURL string, logger *slog.Logger) (Service, error) { @@ -30,3 +36,25 @@ func ServiceFactory(t string, config []byte, prio int, ackURL string, logger *sl return nil, werr.New("Unknown notification service, '%s'", t).WithCode("002-0000") } + +func GetInstance(typ string) Service { + for _, svc := range allServices { + if strings.ToLower(svc.GetType()) == strings.ToLower(typ) { + return svc + } + } + return nil +} + +func AvailableServices() []Service { + slices.SortFunc(allServices, func(a, b Service) int { + if a.GetType() < b.GetType() { + return -1 + } + if a.GetType() > b.GetType() { + return 1 + } + return 0 + }) + return allServices +} diff --git a/notification/ntfy.go b/notification/ntfy.go index 51b5896..0e94516 100644 --- a/notification/ntfy.go +++ b/notification/ntfy.go @@ -11,6 +11,9 @@ import ( "io" "log/slog" "net/http" + "net/url" + "strconv" + "strings" ) type NTFY struct { @@ -18,6 +21,13 @@ type NTFY struct { Prio int AcknowledgeURL string logger *slog.Logger + + exists bool + updated Service +} + +func init() { + allServices = append(allServices, &NTFY{}) } func NewNTFY(config []byte, prio int, ackURL string) (instance *NTFY, err error) { @@ -44,6 +54,22 @@ func (ntfy *NTFY) GetPrio() int { return ntfy.Prio } +func (ntfy *NTFY) SetPrio(prio int) { + ntfy.Prio = prio +} + +func (ntfy *NTFY) SetExists(exists bool) { + ntfy.exists = exists +} + +func (ntfy NTFY) Exists() bool { + return ntfy.exists +} + +func (ntfy *NTFY) String() string { + return ntfy.URL +} + func (ntfy NTFY) Send(problemID int, msg []byte) (err error) { var req *http.Request var res *http.Response @@ -81,3 +107,40 @@ func (ntfy NTFY) Send(problemID int, msg []byte) (err error) { return } + +func (ntfy *NTFY) Update(values url.Values) (err error) { + updated := NTFY{} + ntfy.updated = &updated + + updated.Prio, err = strconv.Atoi(values.Get("prio")) + if err != nil { + return werr.Wrap(err) + } + + givenURL := values.Get("url") + if strings.TrimSpace(givenURL) == "" { + return werr.New("URL cannot be empty") + } + updated.URL = strings.TrimSpace(givenURL) + return +} + +func (ntfy *NTFY) Updated() Service { + return ntfy.updated +} + +func (ntfy *NTFY) Commit() { + updatedNTFY := ntfy.updated.(*NTFY) + ntfy.Prio = updatedNTFY.Prio + ntfy.URL = updatedNTFY.URL +} + +func (ntfy NTFY) JSON() []byte { + data := struct { + URL string `json:"url"` + }{ + ntfy.URL, + } + j, _ := json.Marshal(data) + return j +} diff --git a/notification/pkg.go b/notification/pkg.go index cc8bbcc..dba9747 100644 --- a/notification/pkg.go +++ b/notification/pkg.go @@ -6,6 +6,7 @@ import ( // Standard "log/slog" + "net/url" "slices" ) @@ -13,7 +14,15 @@ type Service interface { SetLogger(*slog.Logger) GetPrio() int GetType() string + SetPrio(int) + SetExists(bool) // Exists in database + Exists() bool // Exists in database + String() string Send(int, []byte) error + Update(url.Values) error + Updated() Service + Commit() + JSON() []byte } type Manager struct { @@ -28,6 +37,7 @@ func NewManager(logger *slog.Logger) (nm Manager) { } func (nm *Manager) AddService(service Service) { + service.SetExists(true) nm.services = append(nm.services, service) slices.SortFunc(nm.services, func(a, b Service) int { if a.GetPrio() < b.GetPrio() { @@ -40,6 +50,16 @@ func (nm *Manager) AddService(service Service) { }) } +func (nm *Manager) GetService(prio int) *Service { + for _, svc := range nm.services { + if svc.GetPrio() == prio { + return &svc + } + } + + return nil +} + func (nm *Manager) Send(problemID int, msg []byte, fn func(*Service, error)) (err error) { for i, service := range nm.services { nm.logger.Info("notification", "service", service.GetType(), "prio", service.GetPrio()) @@ -49,7 +69,7 @@ func (nm *Manager) Send(problemID int, msg []byte, fn func(*Service, error)) (er } else { data := struct { ProblemID int - Msg []byte + Msg []byte }{ problemID, msg, @@ -61,3 +81,7 @@ func (nm *Manager) Send(problemID int, msg []byte, fn func(*Service, error)) (er return } + +func (nm *Manager) Services() (services []Service) { + return nm.services +} diff --git a/notification/script.go b/notification/script.go index 1e8fcca..ab6492f 100644 --- a/notification/script.go +++ b/notification/script.go @@ -7,6 +7,7 @@ import ( // Standard "encoding/json" "log/slog" + "net/url" "os/exec" "strconv" "strings" @@ -17,6 +18,13 @@ type Script struct { Prio int AcknowledgeURL string logger *slog.Logger + + exists bool + updated Service +} + +func init() { + allServices = append(allServices, &Script{}) } func NewScript(config []byte, prio int, ackURL string) (instance *Script, err error) { @@ -43,6 +51,22 @@ func (script *Script) GetPrio() int { return script.Prio } +func (script *Script) SetPrio(prio int) { + script.Prio = prio +} + +func (script *Script) SetExists(exists bool) { + script.exists = exists +} + +func (script Script) Exists() bool { + return script.exists +} + +func (script *Script) String() string { + return script.Filename +} + func (script Script) Send(problemID int, msg []byte) (err error) { var errbuf strings.Builder cmd := exec.Command(script.Filename, strconv.Itoa(problemID), script.AcknowledgeURL, string(msg)) @@ -68,3 +92,40 @@ func (script Script) Send(problemID int, msg []byte) (err error) { return } + +func (script *Script) Update(values url.Values) (err error) { + updated := Script{} + updated.Prio, err = strconv.Atoi(values.Get("prio")) + if err != nil { + return werr.Wrap(err) + } + + givenFilename := values.Get("filename") + if strings.TrimSpace(givenFilename) == "" { + return werr.New("Filename cannot be empty") + } + updated.Filename = strings.TrimSpace(givenFilename) + + script.updated = &updated + return +} + +func (script *Script) Updated() Service { + return script.updated +} + +func (script *Script) Commit() { + updated := script.updated.(*Script) + script.Prio = updated.Prio + script.Filename = updated.Filename +} + +func (script Script) JSON() []byte { + data := struct { + Filename string `json:"filename"` + }{ + script.Filename, + } + j, _ := json.Marshal(data) + return j +} diff --git a/notification_manager.go b/notification_manager.go index 328819b..74b2691 100644 --- a/notification_manager.go +++ b/notification_manager.go @@ -3,6 +3,7 @@ package main import ( // External werr "git.gibonuddevalla.se/go/wrappederror" + "github.com/lib/pq" // Internal "smon/notification" @@ -67,13 +68,61 @@ func InitNotificationManager() (nm notification.Manager, err error) { // {{{ return } // }}} +func UpdateNotificationService(svc notification.Service) (created bool, err error) { // {{{ + if svc.Exists() { + _, err = service.Db.Conn.Exec( + ` + UPDATE public.notification + SET + prio=$2, + configuration=$3 + WHERE + prio=$1 + `, + svc.GetPrio(), + svc.Updated().GetPrio(), + svc.Updated().JSON(), + ) + } else { + _, err = service.Db.Conn.Exec( + ` + INSERT INTO public.notification(prio, configuration, service) + VALUES($1, $2, $3) + + `, + svc.Updated().GetPrio(), + svc.Updated().JSON(), + svc.GetType(), + ) + created = true + } + + if err != nil { + // Check if this is just a duplicated prio, which isn't allowed. + pgErr, isPgErr := err.(*pq.Error) + if isPgErr && pgErr.Code == "23505" { + return false, werr.New("Prio %d is already used by another service", svc.GetPrio()) + } + + return false, werr.Wrap(err).WithData( + struct { + Prio int + Configuration []byte + }{ + svc.GetPrio(), + svc.JSON(), + }, + ) + } + 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) - } + _, 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 } // }}} diff --git a/static/css/default_light/configuration.css b/static/css/default_light/configuration.css index 83a9658..715f085 100644 --- a/static/css/default_light/configuration.css +++ b/static/css/default_light/configuration.css @@ -24,3 +24,20 @@ #areas .area .section.configuration img { height: 16px; } +#services { + display: grid; + grid-template-columns: repeat(3, min-content); + gap: 10px 24px; + background-color: #2979b8; + width: min-content; + padding: 16px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + margin-top: 16px; +} +#services .header { + font-weight: bold; +} +#services div { + white-space: nowrap; +} diff --git a/static/css/default_light/default_light.css b/static/css/default_light/default_light.css index 7b269fc..57e3f8c 100644 --- a/static/css/default_light/default_light.css +++ b/static/css/default_light/default_light.css @@ -43,7 +43,8 @@ button:focus { #datapoints, #problems-list, #acknowledged-list, -#values { +#values, +#services { background-color: #fff !important; border: 1px solid #ddd; box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25); diff --git a/static/css/default_light/gruvbox.css b/static/css/default_light/gruvbox.css index 621bb00..54b45b4 100644 --- a/static/css/default_light/gruvbox.css +++ b/static/css/default_light/gruvbox.css @@ -4,3 +4,9 @@ body { #areas .area { box-shadow: 5px 5px 15px 0px rgba(0, 0, 0, 0.5); } +#page-error { + border: unset; + color: #fff; + background-color: #a00; + text-align: center; +} diff --git a/static/css/gruvbox/configuration.css b/static/css/gruvbox/configuration.css index 83a9658..063f37d 100644 --- a/static/css/gruvbox/configuration.css +++ b/static/css/gruvbox/configuration.css @@ -24,3 +24,20 @@ #areas .area .section.configuration img { height: 16px; } +#services { + display: grid; + grid-template-columns: repeat(3, min-content); + gap: 10px 24px; + background-color: #333; + width: min-content; + padding: 16px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + margin-top: 16px; +} +#services .header { + font-weight: bold; +} +#services div { + white-space: nowrap; +} diff --git a/static/css/gruvbox/default_light.css b/static/css/gruvbox/default_light.css index dd367fa..3361608 100644 --- a/static/css/gruvbox/default_light.css +++ b/static/css/gruvbox/default_light.css @@ -43,7 +43,8 @@ button:focus { #datapoints, #problems-list, #acknowledged-list, -#values { +#values, +#services { background-color: #fff !important; border: 1px solid #ddd; box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25); diff --git a/static/css/gruvbox/gruvbox.css b/static/css/gruvbox/gruvbox.css index 621bb00..54b45b4 100644 --- a/static/css/gruvbox/gruvbox.css +++ b/static/css/gruvbox/gruvbox.css @@ -4,3 +4,9 @@ body { #areas .area { box-shadow: 5px 5px 15px 0px rgba(0, 0, 0, 0.5); } +#page-error { + border: unset; + color: #fff; + background-color: #a00; + text-align: center; +} diff --git a/static/less/configuration.less b/static/less/configuration.less index 33a8323..b1c41fd 100644 --- a/static/less/configuration.less +++ b/static/less/configuration.less @@ -36,3 +36,23 @@ } } } + +#services { + display: grid; + grid-template-columns: repeat(3, min-content); + gap: 10px 24px; + background-color: @bg3; + width: min-content; + padding: 16px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + margin-top: 16px; + + .header { + font-weight: bold; + } + + div { + white-space: nowrap; + } +} diff --git a/static/less/default_light.less b/static/less/default_light.less index 27d85a9..542a083 100644 --- a/static/less/default_light.less +++ b/static/less/default_light.less @@ -58,7 +58,7 @@ button { } } -#datapoints, #problems-list, #acknowledged-list, #values { +#datapoints, #problems-list, #acknowledged-list, #values, #services { background-color: #fff !important; border: 1px solid #ddd; box-shadow: 5px 5px 8px 0px rgba(0,0,0,0.25); diff --git a/static/less/gruvbox.less b/static/less/gruvbox.less index 6beb230..cce1dc3 100644 --- a/static/less/gruvbox.less +++ b/static/less/gruvbox.less @@ -9,3 +9,10 @@ body { box-shadow: 5px 5px 15px 0px rgba(0,0,0,0.5); } } + +#page-error { + border: unset; + color: #fff; + background-color: #a00; + text-align: center; +} diff --git a/views/layouts/notification.gotmpl b/views/layouts/notification.gotmpl new file mode 100644 index 0000000..62d95a8 --- /dev/null +++ b/views/layouts/notification.gotmpl @@ -0,0 +1,19 @@ + + + + + + {{ template "fonts" }} + + + + +
+
+ {{ .ERROR }} +
+
+ {{ block "page" . }}{{ end }} +
+ + diff --git a/views/pages/configuration.gotmpl b/views/pages/configuration.gotmpl index 3a9de49..aab74c3 100644 --- a/views/pages/configuration.gotmpl +++ b/views/pages/configuration.gotmpl @@ -62,6 +62,12 @@ return location.href = `/section/delete/${id}` } + + function newNotification() { + const select = document.getElementById('notification-create-type') + const nType = select.value + location.href = `/configuration/notification?type=${nType}` + } {{ block "page_label" . }}{{end}} @@ -108,4 +114,24 @@ +

Notifications

+ + + +
+
Prio
+
Type
+
Target
+
+ {{ range .Data.NotificationServices }} +
{{ .GetPrio }}
+
{{ .GetType }}
+
{{ .String }}
+ {{ end }} +
+ {{ end }} diff --git a/views/pages/notification/ntfy.gotmpl b/views/pages/notification/ntfy.gotmpl new file mode 100644 index 0000000..8408272 --- /dev/null +++ b/views/pages/notification/ntfy.gotmpl @@ -0,0 +1,34 @@ +{{ define "page" }} +

NTFY

+ + +
+ +
+
Prio:
+ + +
URL:
+ + + +
+
+{{ end }} diff --git a/views/pages/notification/script.gotmpl b/views/pages/notification/script.gotmpl new file mode 100644 index 0000000..a408d4a --- /dev/null +++ b/views/pages/notification/script.gotmpl @@ -0,0 +1,34 @@ +{{ define "page" }} +

Script

+ + +
+ +
+
Prio:
+ + +
Filename:
+ + + +
+
+{{ end }}