package main
import (
// External
"git.ahall.se/go/html_template"
werr "git.ahall.se/go/wrappederror"
// Internal
"smon/notification"
// Standard
"context"
"embed"
"encoding/json"
"flag"
"fmt"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http"
"net/url"
"os"
"path"
"slices"
"sort"
"strconv"
"strings"
"time"
)
const VERSION = "v42"
var (
logger *slog.Logger
flagConfigFile string
flagDev bool
flagVersion bool
parsedTemplates map[string]*template.Template
componentFilenames []string
notificationManager notification.Manager
config Config
htmlEngine HTMLTemplate.Engine
//go:embed sql
sqlFS embed.FS
//go:embed views
viewFS embed.FS
//go:embed static
staticFS embed.FS
)
func init() { // {{{
opts := slog.HandlerOptions{}
logger = slog.New(slog.NewJSONHandler(os.Stdout, &opts))
confDir, err := os.UserConfigDir()
if err != nil {
logger.Error("application", "error", werr.Wrap(err))
}
cfgPath := path.Join(confDir, "smon.json")
flag.StringVar(&flagConfigFile, "config", cfgPath, "Path and filename of the JSON configuration file")
flag.BoolVar(&flagDev, "dev", false, "reload templates on each request")
flag.BoolVar(&flagVersion, "version", false, "Display version and exit")
flag.Parse()
componentFilenames, err = getComponentFilenames(viewFS)
if err != nil {
logger.Error("application", "error", err)
os.Exit(1)
}
parsedTemplates = make(map[string]*template.Template)
} // }}}
func main() { // {{{
if flagVersion {
fmt.Println(VERSION)
os.Exit(0)
}
logger.Info("application", "version", VERSION)
var err error
werr.Init()
logger.Info("application", "config", flagConfigFile)
config, err = initConfig(flagConfigFile)
if err != nil {
logger.Error("application", "op", "read_config", "error", err)
os.Exit(1)
}
if config.NodataInterval < 10 {
logger.Error("application → nodata_interval has to be larger or equal to 10.")
os.Exit(1)
}
err = initDb(
config.Database.Host,
config.Database.Port,
config.Database.Name,
config.Database.Username,
config.Database.Password,
)
if err != nil {
logger.Error("database", "error", err)
os.Exit(1)
}
htmlEngine, err = initHTMLTemplate()
if err != nil {
logger.Error("application", "error", err)
return
}
notificationManager, err = initNotificationManager()
if err != nil {
err = werr.Wrap(err).Log()
logger.Error("notification", "error", err)
}
http.HandleFunc("/", staticHandler)
http.HandleFunc("/area/new/{name}", actionAreaNew)
http.HandleFunc("/area/rename/{id}/{name}", actionAreaRename)
http.HandleFunc("/area/delete/{id}", actionAreaDelete)
http.HandleFunc("/section/new/{areaID}/{name}", actionSectionNew)
http.HandleFunc("/section/rename/{id}/{name}", actionSectionRename)
http.HandleFunc("/section/delete/{id}", actionSectionDelete)
http.HandleFunc("/problems", pageProblems)
http.HandleFunc("/problem/acknowledge/{id}", actionProblemAcknowledge)
http.HandleFunc("/problem/unacknowledge/{id}", actionProblemUnacknowledge)
http.HandleFunc("/datapoints", pageDatapoints)
http.HandleFunc("/datapoint/edit/{id}", pageDatapointEdit)
http.HandleFunc("/datapoint/update/{id}", actionDatapointUpdate)
http.HandleFunc("/datapoint/delete/{id}", actionDatapointDelete)
http.HandleFunc("/datapoint/values/{id}", pageDatapointValues)
http.HandleFunc("/datapoint/json/{id}", actionDatapointJson)
http.HandleFunc("/triggers", pageTriggers)
http.HandleFunc("/trigger/create/{sectionID}/{name}", actionTriggerCreate)
http.HandleFunc("/trigger/edit/{id}", pageTriggerEdit)
http.HandleFunc("/trigger/edit/{id}/{sectionID}", pageTriggerEdit)
http.HandleFunc("/trigger/addDatapoint/{id}/{datapointName}", actionTriggerDatapointAdd)
http.HandleFunc("/trigger/update/{id}", actionTriggerUpdate)
http.HandleFunc("/trigger/run/{id}", actionTriggerRun)
http.HandleFunc("/trigger/delete/{id}", actionTriggerDelete)
http.HandleFunc("/notifications", pageNotifications)
http.HandleFunc("/configuration", pageConfiguration)
http.HandleFunc("/configuration/theme", actionConfigurationTheme)
http.HandleFunc("/configuration/timezone", actionConfigurationTimezone)
http.HandleFunc("/configuration/notification", pageConfigurationNotification)
http.HandleFunc("/configuration/notification/update/{prio}", actionConfigurationNotificationUpdate)
http.HandleFunc("/configuration/notification/delete/{prio}", actionConfigurationNotificationDelete)
http.HandleFunc("/entry/{datapoint}", actionEntryDatapoint)
http.HandleFunc("GET /dashboard/values", actionDashboardValues)
http.HandleFunc("POST /dashboard/update", actionDashboardUpdate)
go nodataLoop()
smonConfig, err = SmonConfigInit()
if err != nil {
logger.Error("configuration", "error", werr.Wrap(err))
os.Exit(1)
}
err = smonConfig.Validate()
if err != nil {
logger.Error("configuration", "error", werr.Wrap(err))
os.Exit(1)
}
listen := fmt.Sprintf("%s:%d", config.Network.Address, config.Network.Port)
logger.Info("webserver", "listen", listen)
err = http.ListenAndServe(listen, nil)
if err != nil {
logger.Error("webserver", "error", werr.Wrap(err))
os.Exit(1)
}
} // }}}
func sqlProvider(dbname string, version int) (sql []byte, found bool) { // {{{
var err error
sql, err = sqlFS.ReadFile(fmt.Sprintf("sql/%05d.sql", version))
if err != nil {
return
}
found = true
return
} // }}}
func initHTMLTemplate() (HTMLTemplate.Engine, error) { // {{{
return HTMLTemplate.NewEngine(viewFS, staticFS, flagDev)
} // }}}
func httpError(w http.ResponseWriter, err error) { // {{{
resp := struct {
OK bool
Error string
}{
false,
err.Error(),
}
j, _ := json.Marshal(resp)
w.Write(j)
} // }}}
func pageError(w http.ResponseWriter, redirectURL string, pageErr error) { // {{{
u, err := url.Parse(redirectURL)
if err != nil {
httpError(w, err)
return
}
values := u.Query()
values.Add("_err", pageErr.Error())
u.RawQuery = values.Encode()
w.Header().Add("Location", u.String())
w.WriteHeader(302)
} // }}}
func staticHandler(w http.ResponseWriter, r *http.Request) { // {{{
if flagDev && !reloadTemplates(w) {
return
}
if r.URL.Path == "/" {
pageIndex(w, r)
return
}
htmlEngine.StaticResource(w, r)
} // }}}
func actionEntryDatapoint(w http.ResponseWriter, r *http.Request) { // {{{
dpoint := r.PathValue("datapoint")
value, _ := io.ReadAll(r.Body)
err := DatapointAdd(dpoint, value)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
var triggers []Trigger
triggers, err = TriggersRetrieveByDatapoint(dpoint)
if err != nil {
logger.Error("entry", "error", err)
}
// Multiple triggers can use the same datapoint.
for _, trigger := range triggers {
var out any
out, err = trigger.Run()
if err != nil {
err = werr.Wrap(err).Log()
logger.Error("entry", "error", err)
}
logger.Debug("entry", "datapoint", dpoint, "value", value, "trigger", trigger, "result", out)
var problemID int
var problemState string
switch v := out.(type) {
case bool:
// Trigger returning true - a problem occurred
if v {
problemID, err = ProblemStart(trigger)
if err != nil {
err = werr.Wrap(err).Log()
logger.Error("entry", "error", err)
}
problemState = "PROBLEM"
} else {
// A problem didn't occur.
problemID, err = ProblemClose(trigger)
if err != nil {
err = werr.Wrap(err).Log()
logger.Error("entry", "error", err)
}
problemState = "OK"
}
// Has a change in problem state happened?
if problemID == 0 && err == nil {
logger.Debug("notification", "trigger", trigger.ID, "state", "no change")
continue
} else {
logger.Debug("notification", "trigger", trigger.ID, "state", "change")
}
err = notificationManager.Send(problemID, []byte(problemState+": "+trigger.Name), func(notificationService *notification.Service, err error) {
logger.Info(
"notification",
"service", (*notificationService).GetType(),
"problemID", problemID,
"prio", (*notificationService).GetPrio(),
"ok", true,
)
var errBody any
if err != nil {
errBody, _ = json.Marshal(err)
} else {
errBody = nil
}
_, err = db.Exec(
context.Background(),
`
INSERT INTO notification_send(notification_id, problem_id, uuid, ok, error)
SELECT
id, $3, '', $4, $5
FROM notification
WHERE
service=$1 AND
prio=$2
`,
(*notificationService).GetType(),
(*notificationService).GetPrio(),
problemID,
err == nil,
errBody,
)
if err != nil {
err = werr.Wrap(err).Log()
logger.Error("entry", "error", err)
}
})
if err != nil {
err = werr.Wrap(err).Log()
logger.Error("notification", "error", err)
}
default:
err := fmt.Errorf(`Expression for trigger %s not returning bool (%T)`, trigger.Name, v)
logger.Info("entry", "error", err)
werr.Wrap(err).WithData(v).Log()
}
}
j, _ := json.Marshal(struct{ OK bool }{true})
w.Write(j)
} // }}}
func reloadTemplates(w http.ResponseWriter) bool { // {{{
parsedTemplates = make(map[string]*template.Template)
return true
} // }}}
func getComponentFilenames(efs fs.FS) (files []string, err error) { // {{{
files = []string{}
if err := fs.WalkDir(efs, "views/components", func(path string, d fs.DirEntry, err error) error {
if d == nil {
return nil
}
if d.IsDir() {
return nil
}
files = append(files, path)
return nil
}); err != nil {
return nil, err
}
return files, nil
} // }}}
func getPage(layout, page string) (tmpl *template.Template, err error) { // {{{
layoutFilename := fmt.Sprintf("views/layouts/%s.gotmpl", layout)
pageFilename := fmt.Sprintf("views/pages/%s.gotmpl", page)
if tmpl, found := parsedTemplates[page]; found {
return tmpl, nil
}
funcMap := template.FuncMap{
"format_time": func(t time.Time) template.HTML {
return template.HTML(
t.In(smonConfig.Timezone()).Format(`2006-01-02 15:04:05:05`),
)
},
}
filenames := []string{layoutFilename, pageFilename}
filenames = append(filenames, componentFilenames...)
logger.Info("template", "op", "parse", "layout", layout, "page", page, "filenames", filenames)
if flagDev {
tmpl, err = template.New(layout+".gotmpl").Funcs(funcMap).ParseFS(os.DirFS("."), filenames...)
} else {
tmpl, err = template.New(layout+".gotmpl").Funcs(funcMap).ParseFS(viewFS, filenames...)
}
if err != nil {
err = werr.Wrap(err).Log()
return
}
parsedTemplates[page] = tmpl
return
} // }}}
func pageIndex(w http.ResponseWriter, r *http.Request) { // {{{
page := Page{
LAYOUT: "main",
PAGE: "index",
CONFIG: smonConfig.Settings,
}
dashboard, err := DashboardGet("start")
if err != nil {
logger.Error("dashboard", "error", err)
}
data := make(map[string]any)
data["Dashboard"] = dashboard
page.Data = data
page.Render(w, r)
} // }}}
func actionDashboardValues(w http.ResponseWriter, r *http.Request) { // {{{
values, err := DatapointsValue([]int{13, 15})
if err != nil {
logger.Error("dashboard", "error", err)
httpError(w, err)
return
}
j, _ := json.Marshal(struct {
OK bool
Values []DatapointTiny
}{
true,
values,
})
w.Write(j)
} // }}}
func actionDashboardUpdate(w http.ResponseWriter, r *http.Request) { // {{{
var dashboard Dashboard
body, _ := io.ReadAll(r.Body)
err := json.Unmarshal(body, &dashboard)
if err != nil {
httpError(w, werr.Wrap(err))
return
}
err = DashboardUpdate(dashboard)
if err != nil {
httpError(w, werr.Wrap(err))
return
}
j, _ := json.Marshal(struct{ OK bool }{true})
w.Write(j)
} // }}}
func actionAreaNew(w http.ResponseWriter, r *http.Request) { // {{{
name := r.PathValue("name")
err := AreaCreate(name)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}
func actionAreaRename(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
name := r.PathValue("name")
err = AreaRename(id, name)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}
func actionAreaDelete(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).WithData(idStr).Log())
return
}
err = AreaDelete(id)
if err != nil {
httpError(w, werr.Wrap(err).WithData(id).Log())
return
}
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}
func actionSectionNew(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("areaID")
areaID, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
name := r.PathValue("name")
err = SectionCreate(areaID, name)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}
func actionSectionRename(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
name := r.PathValue("name")
err = SectionRename(id, name)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}
func actionSectionDelete(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).WithData(idStr).Log())
return
}
err = SectionDelete(id)
if err != nil {
httpError(w, werr.Wrap(err).WithData(id).Log())
return
}
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}
func pageProblems(w http.ResponseWriter, r *http.Request) { // {{{
page := Page{
LAYOUT: "main",
PAGE: "problems",
CONFIG: smonConfig.Settings,
}
// Manage the values from the timefilter component
var err error
var timeFrom, timeTo time.Time
timeFrom, timeTo, err = timefilterParse(
r.URL.Query().Get("time-f"),
r.URL.Query().Get("time-t"),
r.URL.Query().Get("time-offset"),
r.URL.Query().Get("time-preset"),
)
if err != nil {
httpError(w, err)
return
}
// GET parameters for this page
var selection string
if r.URL.Query().Get("selection") == "all" {
selection = "ALL"
} else {
selection = "CURRENT"
}
problems, err := ProblemsRetrieve(selection == "ALL", timeFrom, timeTo)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
var found bool
problemsGrouped := make(map[string]map[string][]Problem)
for _, problem := range problems {
if _, found = problemsGrouped[problem.AreaName]; !found {
problemsGrouped[problem.AreaName] = make(map[string][]Problem)
}
if _, found = problemsGrouped[problem.AreaName][problem.SectionName]; !found {
problemsGrouped[problem.AreaName][problem.SectionName] = []Problem{}
}
problemsGrouped[problem.AreaName][problem.SectionName] = append(
problemsGrouped[problem.AreaName][problem.SectionName],
problem,
)
}
page.Data = map[string]any{
"Problems": problems,
"ProblemsGrouped": problemsGrouped,
"Selection": selection,
"TimeHidden": selection == "CURRENT",
"TimeSubmit": "/problems",
"TimeFrom": timeFrom.Format("2006-01-02T15:04:05"),
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
}
page.Render(w, r)
} // }}}
func actionProblemAcknowledge(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
err = ProblemAcknowledge(id, true)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/problems")
w.WriteHeader(302)
} // }}}
func actionProblemUnacknowledge(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
err = ProblemAcknowledge(id, false)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/problems")
w.WriteHeader(302)
} // }}}
func pageDatapoints(w http.ResponseWriter, r *http.Request) { // {{{
page := Page{
LAYOUT: "main",
PAGE: "datapoints",
CONFIG: smonConfig.Settings,
}
datapoints, err := DatapointsRetrieve()
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
// The datapoint selector in trigger edit wants the raw data in JSON.
if r.URL.Query().Get("format") == "json" {
j, _ := json.Marshal(datapoints)
w.Write(j)
return
}
page.Data = map[string]any{
"Datapoints": datapoints,
}
page.Render(w, r)
} // }}}
func pageDatapointEdit(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
var datapoint Datapoint
if id == 0 {
datapoint.Name = "new_datapoint"
datapoint.Datatype = "INT"
} else {
datapoint, err = DatapointRetrieve(id, "")
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
}
/* Triggers using this datapoint is provided as a list to update
* if changing the datapoint name. Parsing expr and automatically
* changing it to renamed datapoints would be nice in the future. */
var triggers []Trigger
triggers, err = TriggersRetrieveByDatapoint(datapoint.Name)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
slices.SortFunc(triggers, func(a, b Trigger) int {
an := strings.ToUpper(a.Name)
bn := strings.ToUpper(b.Name)
if an < bn {
return -1
}
if an > bn {
return 1
}
return 0
})
page := Page{
LAYOUT: "main",
PAGE: "datapoint_edit",
CONFIG: smonConfig.Settings,
MENU: "datapoints",
Icon: "datapoints",
Label: "Datapoint",
}
page.Data = map[string]any{
"Datapoint": datapoint,
"Triggers": triggers,
}
page.Render(w, r)
} // }}}
func actionDatapointUpdate(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
var nodataSeconds int
nodataSeconds, _ = strconv.Atoi(r.FormValue("nodata_seconds"))
// Datapoint needs to be retrieved from database for the name.
// If name has changed, trigger expressions needs to be updated.
var dp Datapoint
dp, err = DatapointRetrieve(id, "")
if err != nil {
httpError(w, werr.Wrap(err).WithData(id).Log())
return
}
prevDatapointName := dp.Name
dp.Group = r.FormValue("group")
dp.Name = r.FormValue("name")
dp.Datatype = DatapointType(r.FormValue("datatype"))
dp.Comment = r.FormValue("comment")
dp.NodataProblemSeconds = nodataSeconds
err = dp.Update()
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
// Update the trigger expressions using this
// datapoint name if changed.
if prevDatapointName != dp.Name {
var triggers []Trigger
triggers, err = TriggersRetrieveByDatapoint(dp.Name)
if err != nil {
httpError(w, werr.Wrap(err).WithData(dp.Name))
return
}
for _, trigger := range triggers {
err = trigger.RenameDatapoint(prevDatapointName, dp.Name)
if err != nil {
httpError(w, werr.Wrap(err).WithData([]string{prevDatapointName, dp.Name}))
return
}
err = trigger.Update()
if err != nil {
httpError(w, werr.Wrap(err).WithData([]string{prevDatapointName, dp.Name, trigger.Name}))
return
}
}
}
w.Header().Add("Location", "/datapoints")
w.WriteHeader(302)
} // }}}
func actionDatapointDelete(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
err = DatapointDelete(id)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/datapoints")
w.WriteHeader(302)
} // }}}
func pageDatapointValues(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
var datapoint Datapoint
datapoint, err = DatapointRetrieve(id, "")
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
// Manage the values from the timefilter component
var timeFrom, timeTo time.Time
timeFrom, timeTo, err = timefilterParse(
r.URL.Query().Get("time-f"),
r.URL.Query().Get("time-t"),
r.URL.Query().Get("time-offset"),
r.URL.Query().Get("time-preset"),
)
// Fetch data point values according to the times.
var values []DatapointValue
values, err = DatapointValues(id, timeFrom, timeTo)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
// GET parameters.
display := r.URL.Query().Get("display")
if display == "" && datapoint.Datatype == INT {
display = "graph"
}
page := Page{
LAYOUT: "main",
PAGE: "datapoint_values",
CONFIG: smonConfig.Settings,
MENU: "datapoints",
Icon: "datapoints",
Label: "Values for " + datapoint.Name,
}
page.Data = map[string]any{
"Datapoint": datapoint,
"Values": values,
"Display": display,
"TimeSubmit": "/datapoint/values/" + strconv.Itoa(datapoint.ID),
"TimeFrom": timeFrom.Format("2006-01-02T15:04:05"),
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
}
page.Render(w, r)
} // }}}
func actionDatapointJson(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
fromStr := r.URL.Query().Get("time-f")
from, err := time.ParseInLocation("2006-01-02 15:04:05", fromStr[0:min(19, len(fromStr))], smonConfig.Timezone())
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
toStr := r.URL.Query().Get("time-t")
to, err := time.ParseInLocation("2006-01-02 15:04:05", toStr[0:min(19, len(toStr))], smonConfig.Timezone())
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
values, err := DatapointValues(id, from, to)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
j, _ := json.Marshal(values)
w.Write(j)
} // }}}
func pageTriggers(w http.ResponseWriter, r *http.Request) { // {{{
areas, err := TriggersRetrieve()
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
sort.SliceStable(areas, func(i, j int) bool {
return areas[i].Name < areas[j].Name
})
page := Page{
LAYOUT: "main",
PAGE: "triggers",
CONFIG: smonConfig.Settings,
Data: map[string]any{
"Areas": areas,
},
}
page.Render(w, r)
} // }}}
func actionTriggerCreate(w http.ResponseWriter, r *http.Request) { // {{{
name := r.PathValue("name")
sectionIDStr := r.PathValue("sectionID")
sectionID, err := strconv.Atoi(sectionIDStr)
if err != nil {
httpError(w, werr.Wrap(err).WithData(sectionIDStr).Log())
return
}
t, err := TriggerCreate(sectionID, name)
if err != nil {
httpError(w, werr.Wrap(err).WithData(struct {
SectionID int
Name string
}{
sectionID,
name,
}).Log())
return
}
resp := struct {
OK bool
Trigger Trigger
}{
true,
t,
}
j, _ := json.Marshal(resp)
w.Header().Add("Content-Type", "application/json")
w.Write(j)
} // }}}
func pageTriggerEdit(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
// Creating a new trigger uses the edit function.
// ID == 0 - create a new trigger.
// ID > 0 - edit existing trigger.
var trigger Trigger
if id > 0 {
trigger, err = TriggerRetrieve(id)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
} else {
// A new trigger needs to know which section it belongs to.
sectionIDStr := r.PathValue("sectionID")
if sectionIDStr != "" {
trigger.SectionID, err = strconv.Atoi(sectionIDStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
}
}
datapoints := make(map[string]Datapoint)
for _, dpname := range trigger.Datapoints {
dp, err := DatapointRetrieve(0, dpname)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
dp.LastDatapointValue.TemplateValue = dp.LastDatapointValue.Value()
datapoints[dpname] = dp
}
page := Page{
LAYOUT: "main",
PAGE: "trigger_edit",
MENU: "triggers",
CONFIG: smonConfig.Settings,
Label: "Trigger",
Icon: "triggers",
Data: map[string]any{
"Trigger": trigger,
"Datapoints": datapoints,
},
}
page.Render(w, r)
} // }}}
func actionTriggerDatapointAdd(w http.ResponseWriter, r *http.Request) { // {{{
triggerID := r.PathValue("id")
dpName := r.PathValue("datapointName")
id, err := strconv.Atoi(triggerID)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
var trigger Trigger
if id > 0 {
trigger, err = TriggerRetrieve(id)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
}
if !slices.Contains(trigger.Datapoints, dpName) {
trigger.Datapoints = append(trigger.Datapoints, dpName)
}
err = trigger.Update()
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
// Also retrieve the datapoint to get the latest value
// for immediate presentation when added.
dp, err := DatapointRetrieve(0, dpName)
if err != nil {
httpError(w, werr.Wrap(err).WithData(dpName).Log())
return
}
dp.LastDatapointValue.TemplateValue = dp.LastDatapointValue.Value()
j, _ := json.Marshal(
struct {
OK bool
Datapoint Datapoint
}{
true,
dp,
})
w.Header().Add("Content-Type", "application/json")
w.Write(j)
} // }}}
func actionTriggerUpdate(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
var trigger Trigger
if id > 0 {
trigger, err = TriggerRetrieve(id)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
} else {
trigger.SectionID, err = strconv.Atoi(r.FormValue("sectionID"))
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
}
trigger.Name = r.FormValue("name")
trigger.Expression = r.FormValue("expression")
trigger.Datapoints = r.Form["datapoints[]"]
err = trigger.Update()
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/triggers")
w.WriteHeader(302)
} // }}}
func actionTriggerRun(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
var trigger Trigger
trigger, err = TriggerRetrieve(id)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
expr, _ := io.ReadAll(r.Body)
trigger.Expression = string(expr)
resp := struct {
OK bool
Output any
}{
OK: true,
}
resp.Output, err = trigger.Run()
if err != nil {
werr.Wrap(err).Log()
httpError(w, err)
return
}
j, _ := json.Marshal(resp)
w.Header().Add("Content-Type", "application/json")
w.Write(j)
} // }}}
func actionTriggerDelete(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
err = TriggerDelete(id)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/triggers")
w.WriteHeader(302)
} // }}}
func pageConfiguration(w http.ResponseWriter, r *http.Request) { // {{{
areas, err := AreaRetrieve()
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
sort.SliceStable(areas, func(i, j int) bool {
return areas[i].Name < areas[j].Name
})
page := Page{
LAYOUT: "main",
PAGE: "configuration",
CONFIG: smonConfig.Settings,
Data: map[string]any{
"Areas": areas,
"NotificationServices": notificationManager.Services(),
"AvailableServices": notification.AvailableServices(),
},
}
page.Render(w, r)
} // }}}
func actionConfigurationTheme(w http.ResponseWriter, r *http.Request) { // {{{
theme := r.FormValue("theme")
err := smonConfig.SetTheme(theme)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}
func actionConfigurationTimezone(w http.ResponseWriter, r *http.Request) { // {{{
timezone := r.FormValue("timezone")
_, err := time.LoadLocation(timezone)
if err != nil {
pageError(w, "/configuration", werr.Wrap(err).Log())
return
}
err = smonConfig.SetTimezone(timezone)
if err != nil {
pageError(w, "/configuration", werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}
func pageConfigurationNotification(w http.ResponseWriter, r *http.Request) { // {{{
// 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.NewInstance(notificationType)
logger.Info("FOO", "service", fmt.Sprintf("%p %#v\n", service, service))
} 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) { // {{{
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.NewInstance(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)
}
notificationManager.Reprioritize()
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}
func actionConfigurationNotificationDelete(w http.ResponseWriter, r *http.Request) { // {{{
prioStr := r.PathValue("prio")
prio, err := strconv.Atoi(prioStr)
if err != nil {
pageError(w, "/configuration", werr.Wrap(err).Log())
return
}
DeleteNotificationService(prio)
notificationManager.RemoveService(prio)
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}
func pageNotifications(w http.ResponseWriter, r *http.Request) { // {{{
var err error
// Manage the values from the timefilter component
var timeFrom, timeTo time.Time
timeFrom, timeTo, err = timefilterParse(
r.URL.Query().Get("time-f"),
r.URL.Query().Get("time-t"),
r.URL.Query().Get("time-offset"),
r.URL.Query().Get("time-preset"),
)
nss, err := notificationsSent(timeFrom, timeTo)
if err != nil {
pageError(w, "/", werr.Wrap(err).Log())
}
page := Page{
LAYOUT: "main",
PAGE: "notifications",
CONFIG: smonConfig.Settings,
Data: map[string]any{
"Notifications": nss,
"TimeSubmit": "/notifications",
"TimeFrom": timeFrom.Format("2006-01-02T15:04:05"),
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
},
}
page.Render(w, r)
} // }}}