smon/main.go

1317 lines
32 KiB
Go
Raw Permalink Normal View History

2024-04-29 08:36:13 +02:00
package main
import (
// External
ws "git.gibonuddevalla.se/go/webservice"
"git.gibonuddevalla.se/go/webservice/session"
2024-05-05 10:10:04 +02:00
werr "git.gibonuddevalla.se/go/wrappederror"
// Internal
"smon/notification"
2024-04-29 08:36:13 +02:00
// Standard
"embed"
"encoding/json"
"flag"
"fmt"
"html/template"
"io"
"io/fs"
"log/slog"
"net/http"
2024-06-27 10:02:11 +02:00
"net/url"
2024-04-29 08:36:13 +02:00
"os"
"path"
"slices"
2024-04-29 08:36:13 +02:00
"sort"
"strconv"
2024-06-28 15:28:52 +02:00
"strings"
2024-04-30 08:04:16 +02:00
"time"
2024-04-29 08:36:13 +02:00
)
2024-07-25 10:57:05 +02:00
const VERSION = "v41"
2024-04-29 08:36:13 +02:00
var (
2024-05-05 10:10:04 +02:00
logger *slog.Logger
flagConfigFile string
flagDev bool
2024-05-20 13:21:22 +02:00
flagVersion bool
2024-05-05 10:10:04 +02:00
service *ws.Service
logFile *os.File
parsedTemplates map[string]*template.Template
componentFilenames []string
notificationManager notification.Manager
2024-06-27 09:09:47 +02:00
fileConf FileConfiguration
2024-04-29 08:36:13 +02:00
//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 {
2024-05-05 10:10:04 +02:00
logger.Error("application", "error", werr.Wrap(err))
2024-04-29 08:36:13 +02:00
}
cfgPath := path.Join(confDir, "smon.yaml")
flag.StringVar(&flagConfigFile, "config", cfgPath, "Path and filename of the YAML configuration file")
flag.BoolVar(&flagDev, "dev", false, "reload templates on each request")
2024-05-20 13:21:22 +02:00
flag.BoolVar(&flagVersion, "version", false, "Display version and exit")
2024-04-29 08:36:13 +02:00
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() { // {{{
2024-05-20 13:21:22 +02:00
if flagVersion {
fmt.Println(VERSION)
os.Exit(0)
}
2024-04-29 08:36:13 +02:00
var err error
2024-05-05 10:10:04 +02:00
werr.Init()
werr.SetLogCallback(logHandler)
2024-04-29 08:36:13 +02:00
service, err = ws.New(flagConfigFile, VERSION, logger)
if err != nil {
logger.Error("application", "error", err)
os.Exit(1)
}
j, _ := json.Marshal(service.Config.Application)
2024-06-27 09:09:47 +02:00
json.Unmarshal(j, &fileConf)
logFile, err = os.OpenFile(fileConf.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
2024-04-29 08:36:13 +02:00
if err != nil {
logger.Error("application", "error", err)
return
}
2024-06-27 09:09:47 +02:00
if fileConf.NodataInterval < 10 {
logger.Error("application → nodata_interval has to be larger or equal to 10.")
return
}
2024-04-29 08:36:13 +02:00
service.SetDatabase(sqlProvider)
service.SetStaticFS(staticFS, "static")
service.SetStaticDirectory("static", flagDev)
2024-04-30 08:04:16 +02:00
service.InitDatabaseConnection()
err = service.Db.Connect()
if err != nil {
logger.Error("application", "error", err)
return
}
notificationManager, err = InitNotificationManager()
2024-05-05 10:10:04 +02:00
if err != nil {
err = werr.Wrap(err).Log()
logger.Error("notification", "error", err)
}
2024-04-29 08:36:13 +02:00
service.Register("/", false, false, staticHandler)
2024-06-02 10:59:06 +02:00
service.Register("/area/new/{name}", false, false, actionAreaNew)
service.Register("/area/rename/{id}/{name}", false, false, actionAreaRename)
service.Register("/area/delete/{id}", false, false, actionAreaDelete)
2024-06-02 10:59:06 +02:00
service.Register("/section/new/{areaID}/{name}", false, false, actionSectionNew)
service.Register("/section/rename/{id}/{name}", false, false, actionSectionRename)
service.Register("/section/delete/{id}", false, false, actionSectionDelete)
2024-04-29 08:36:13 +02:00
service.Register("/problems", false, false, pageProblems)
2024-06-02 10:59:06 +02:00
service.Register("/problem/acknowledge/{id}", false, false, actionProblemAcknowledge)
service.Register("/problem/unacknowledge/{id}", false, false, actionProblemUnacknowledge)
2024-05-01 10:02:33 +02:00
2024-04-30 08:04:16 +02:00
service.Register("/datapoints", false, false, pageDatapoints)
service.Register("/datapoint/edit/{id}", false, false, pageDatapointEdit)
2024-06-02 10:59:06 +02:00
service.Register("/datapoint/update/{id}", false, false, actionDatapointUpdate)
service.Register("/datapoint/delete/{id}", false, false, actionDatapointDelete)
2024-05-05 20:16:28 +02:00
service.Register("/datapoint/values/{id}", false, false, pageDatapointValues)
2024-06-27 16:53:30 +02:00
service.Register("/datapoint/json/{id}", false, false, actionDatapointJson)
2024-05-01 10:02:33 +02:00
2024-04-29 08:36:13 +02:00
service.Register("/triggers", false, false, pageTriggers)
2024-06-02 10:59:06 +02:00
service.Register("/trigger/create/{sectionID}/{name}", false, false, actionTriggerCreate)
2024-04-29 08:36:13 +02:00
service.Register("/trigger/edit/{id}", false, false, pageTriggerEdit)
2024-05-01 10:02:33 +02:00
service.Register("/trigger/edit/{id}/{sectionID}", false, false, pageTriggerEdit)
2024-06-02 10:59:06 +02:00
service.Register("/trigger/addDatapoint/{id}/{datapointName}", false, false, actionTriggerDatapointAdd)
service.Register("/trigger/update/{id}", false, false, actionTriggerUpdate)
service.Register("/trigger/run/{id}", false, false, actionTriggerRun)
2024-06-01 09:18:56 +02:00
service.Register("/trigger/delete/{id}", false, false, actionTriggerDelete)
2024-05-01 10:02:33 +02:00
2024-06-29 15:20:31 +02:00
service.Register("/notifications", false, false, pageNotifications)
2024-04-29 08:36:13 +02:00
service.Register("/configuration", false, false, pageConfiguration)
2024-06-25 09:18:15 +02:00
service.Register("/configuration/theme", false, false, actionConfigurationTheme)
2024-06-27 10:02:11 +02:00
service.Register("/configuration/timezone", false, false, actionConfigurationTimezone)
2024-06-28 15:28:52 +02:00
service.Register("/configuration/notification", false, false, pageConfigurationNotification)
service.Register("/configuration/notification/update/{prio}", false, false, actionConfigurationNotificationUpdate)
2024-06-28 17:18:08 +02:00
service.Register("/configuration/notification/delete/{prio}", false, false, actionConfigurationNotificationDelete)
2024-06-02 10:59:06 +02:00
service.Register("/entry/{datapoint}", false, false, actionEntryDatapoint)
2024-04-29 08:36:13 +02:00
2024-05-25 09:40:40 +02:00
go nodataLoop()
smonConfig, err = SmonConfigInit()
if err != nil {
logger.Error("configuration", "error", werr.Wrap(err))
os.Exit(1)
}
err = smonConfig.Validate()
2024-06-25 08:59:07 +02:00
if err != nil {
logger.Error("configuration", "error", werr.Wrap(err))
os.Exit(1)
}
2024-04-29 08:36:13 +02:00
err = service.Start()
if err != nil {
2024-05-05 10:10:04 +02:00
logger.Error("webserver", "error", werr.Wrap(err))
2024-04-29 08:36:13 +02:00
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
} // }}}
2024-05-05 10:10:04 +02:00
func logHandler(err werr.Error) { // {{{
2024-04-29 08:36:13 +02:00
j, _ := json.Marshal(err)
logFile.Write(j)
logFile.Write([]byte("\n"))
} // }}}
func httpError(w http.ResponseWriter, err error) { // {{{
resp := struct {
OK bool
Error string
}{
false,
err.Error(),
}
j, _ := json.Marshal(resp)
w.Write(j)
} // }}}
2024-06-27 09:51:52 +02:00
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)
} // }}}
2024-04-29 08:36:13 +02:00
func staticHandler(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
if flagDev && !reloadTemplates(w) {
return
}
if r.URL.Path == "/" {
pageIndex(w, r, sess)
return
}
service.StaticHandler(w, r, sess)
} // }}}
2024-06-02 10:59:06 +02:00
func actionEntryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
2024-04-29 08:36:13 +02:00
dpoint := r.PathValue("datapoint")
value, _ := io.ReadAll(r.Body)
err := DatapointAdd(dpoint, value)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-29 08:36:13 +02:00
return
}
2024-04-30 20:10:05 +02:00
var triggers []Trigger
triggers, err = TriggersRetrieveByDatapoint(dpoint)
if err != nil {
logger.Error("entry", "error", err)
}
2024-05-05 20:16:28 +02:00
2024-05-27 20:03:25 +02:00
// Multiple triggers can use the same datapoint.
2024-04-30 20:10:05 +02:00
for _, trigger := range triggers {
var out any
out, err = trigger.Run()
if err != nil {
2024-05-05 10:10:04 +02:00
err = werr.Wrap(err).Log()
logger.Error("entry", "error", err)
2024-05-01 10:02:33 +02:00
}
logger.Debug("entry", "datapoint", dpoint, "value", value, "trigger", trigger, "result", out)
2024-05-05 20:16:28 +02:00
var problemID int
2024-05-27 20:03:25 +02:00
var problemState string
switch v := out.(type) {
case bool:
2024-05-01 10:02:33 +02:00
// Trigger returning true - a problem occurred
if v {
2024-05-05 20:16:28 +02:00
problemID, err = ProblemStart(trigger)
if err != nil {
2024-05-05 10:10:04 +02:00
err = werr.Wrap(err).Log()
logger.Error("entry", "error", err)
}
2024-05-27 20:03:25 +02:00
problemState = "PROBLEM"
} else {
2024-05-05 20:16:28 +02:00
// A problem didn't occur.
problemID, err = ProblemClose(trigger)
if err != nil {
err = werr.Wrap(err).Log()
logger.Error("entry", "error", err)
}
2024-05-27 20:03:25 +02:00
problemState = "OK"
2024-05-05 20:16:28 +02:00
}
// 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")
}
2024-05-27 20:03:25 +02:00
err = notificationManager.Send(problemID, []byte(problemState+": "+trigger.Name), func(notificationService *notification.Service, err error) {
2024-05-05 20:16:28 +02:00
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 = service.Db.Conn.Exec(
`
INSERT INTO notification_send(notification_id, problem_id, uuid, ok, error)
SELECT
id, $3, '', $4, $5
FROM notification
WHERE
service=$1 AND
prio=$2
2024-05-20 18:57:47 +02:00
`,
2024-05-05 20:16:28 +02:00
(*notificationService).GetType(),
(*notificationService).GetPrio(),
problemID,
err == nil,
errBody,
)
if err != nil {
2024-05-05 10:10:04 +02:00
err = werr.Wrap(err).Log()
logger.Error("entry", "error", err)
}
2024-05-05 20:16:28 +02:00
})
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)
2024-05-05 10:10:04 +02:00
werr.Wrap(err).WithData(v).Log()
}
2024-04-30 20:10:05 +02:00
}
2024-04-29 08:36:13 +02:00
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
}
2024-04-30 08:04:16 +02:00
funcMap := template.FuncMap{
2024-05-01 20:01:43 +02:00
"format_time": func(t time.Time) template.HTML {
return template.HTML(
2024-06-27 13:45:01 +02:00
t.In(smonConfig.Timezone()).Format(`<span class="date">2006-01-02</span> <span class="time">15:04:05<span class="seconds">:05</span></span>`),
2024-05-01 20:01:43 +02:00
)
2024-04-30 08:04:16 +02:00
},
}
2024-04-29 08:36:13 +02:00
filenames := []string{layoutFilename, pageFilename}
filenames = append(filenames, componentFilenames...)
logger.Info("template", "op", "parse", "layout", layout, "page", page, "filenames", filenames)
if flagDev {
2024-06-28 15:28:52 +02:00
tmpl, err = template.New(layout+".gotmpl").Funcs(funcMap).ParseFS(os.DirFS("."), filenames...)
2024-04-29 08:36:13 +02:00
} else {
2024-06-28 15:28:52 +02:00
tmpl, err = template.New(layout+".gotmpl").Funcs(funcMap).ParseFS(viewFS, filenames...)
2024-04-29 08:36:13 +02:00
}
if err != nil {
2024-05-05 10:10:04 +02:00
err = werr.Wrap(err).Log()
2024-04-29 08:36:13 +02:00
return
}
2024-04-30 08:04:16 +02:00
parsedTemplates[page] = tmpl
2024-04-29 08:36:13 +02:00
return
} // }}}
2024-06-27 09:51:52 +02:00
func pageIndex(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-04-29 08:36:13 +02:00
page := Page{
LAYOUT: "main",
PAGE: "index",
2024-06-25 08:59:07 +02:00
CONFIG: smonConfig.Settings,
2024-04-29 08:36:13 +02:00
}
2024-06-27 09:51:52 +02:00
page.Render(w, r)
2024-04-29 08:36:13 +02:00
} // }}}
2024-05-01 10:02:33 +02:00
2024-06-02 10:59:06 +02:00
func actionAreaNew(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
name := r.PathValue("name")
err := AreaCreate(name)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
return
} // }}}
2024-06-02 10:59:06 +02:00
func actionAreaRename(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
return
}
name := r.PathValue("name")
err = AreaRename(id, name)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
return
} // }}}
2024-06-02 10:59:06 +02:00
func actionAreaDelete(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-06-02 09:17:50 +02:00
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)
return
} // }}}
2024-06-02 10:59:06 +02:00
func actionSectionNew(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
idStr := r.PathValue("areaID")
areaID, err := strconv.Atoi(idStr)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
return
}
name := r.PathValue("name")
err = SectionCreate(areaID, name)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
return
} // }}}
2024-06-02 10:59:06 +02:00
func actionSectionRename(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
return
}
name := r.PathValue("name")
err = SectionRename(id, name)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
return
} // }}}
2024-06-02 10:59:06 +02:00
func actionSectionDelete(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-06-02 09:17:50 +02:00
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)
return
} // }}}
2024-06-27 09:51:52 +02:00
func pageProblems(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-04-29 08:36:13 +02:00
page := Page{
LAYOUT: "main",
PAGE: "problems",
2024-06-25 08:59:07 +02:00
CONFIG: smonConfig.Settings,
2024-04-29 08:36:13 +02:00
}
2024-07-04 13:37:06 +02:00
// 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
}
2024-07-04 15:14:24 +02:00
// 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)
2024-05-01 20:01:43 +02:00
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-05-01 20:01:43 +02:00
return
}
2024-04-29 08:36:13 +02:00
2024-05-28 12:56:35 +02:00
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,
)
}
2024-04-29 08:36:13 +02:00
page.Data = map[string]any{
2024-05-28 12:56:35 +02:00
"Problems": problems,
"ProblemsGrouped": problemsGrouped,
2024-07-04 15:14:24 +02:00
"Selection": selection,
2024-07-04 13:37:06 +02:00
2024-07-04 15:14:24 +02:00
"TimeHidden": selection == "CURRENT",
2024-07-04 13:37:06 +02:00
"TimeSubmit": "/problems",
"TimeFrom": timeFrom.Format("2006-01-02T15:04:05"),
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
2024-04-29 08:36:13 +02:00
}
2024-06-27 09:51:52 +02:00
page.Render(w, r)
2024-04-29 08:36:13 +02:00
return
} // }}}
2024-06-02 10:59:06 +02:00
func actionProblemAcknowledge(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-05-01 20:01:43 +02:00
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-05-01 20:01:43 +02:00
return
}
err = ProblemAcknowledge(id, true)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-05-01 20:01:43 +02:00
return
}
w.Header().Add("Location", "/problems")
w.WriteHeader(302)
return
} // }}}
2024-06-02 10:59:06 +02:00
func actionProblemUnacknowledge(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-05-01 20:01:43 +02:00
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-05-01 20:01:43 +02:00
return
}
err = ProblemAcknowledge(id, false)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-05-01 20:01:43 +02:00
return
}
w.Header().Add("Location", "/problems")
w.WriteHeader(302)
return
} // }}}
2024-05-01 10:02:33 +02:00
func pageDatapoints(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-04-30 08:04:16 +02:00
page := Page{
LAYOUT: "main",
PAGE: "datapoints",
2024-06-25 08:59:07 +02:00
CONFIG: smonConfig.Settings,
2024-04-30 08:04:16 +02:00
}
datapoints, err := DatapointsRetrieve()
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-30 08:04:16 +02:00
return
}
2024-05-01 10:02:33 +02:00
// 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
}
2024-04-30 08:04:16 +02:00
page.Data = map[string]any{
"Datapoints": datapoints,
}
2024-06-27 09:51:52 +02:00
page.Render(w, r)
2024-04-30 08:04:16 +02:00
return
} // }}}
func pageDatapointEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-30 08:04:16 +02:00
return
}
var datapoint Datapoint
if id == 0 {
datapoint.Name = "new_datapoint"
datapoint.Datatype = "INT"
} else {
datapoint, err = DatapointRetrieve(id, "")
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-30 08:04:16 +02:00
return
}
}
2024-07-25 08:38:06 +02:00
/* 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
})
2024-04-30 08:04:16 +02:00
page := Page{
LAYOUT: "main",
PAGE: "datapoint_edit",
2024-06-25 08:59:07 +02:00
CONFIG: smonConfig.Settings,
2024-04-30 08:04:16 +02:00
MENU: "datapoints",
Icon: "datapoints",
Label: "Datapoint",
}
page.Data = map[string]any{
"Datapoint": datapoint,
2024-07-25 08:38:06 +02:00
"Triggers": triggers,
2024-04-30 08:04:16 +02:00
}
2024-06-27 09:51:52 +02:00
page.Render(w, r)
2024-04-30 08:04:16 +02:00
return
} // }}}
2024-06-02 10:59:06 +02:00
func actionDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-04-30 08:04:16 +02:00
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-30 08:04:16 +02:00
return
}
2024-05-25 09:40:40 +02:00
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.
2024-04-30 08:04:16 +02:00
var dp Datapoint
dp, err = DatapointRetrieve(id, "")
if err != nil {
httpError(w, werr.Wrap(err).WithData(id).Log())
return
}
prevDatapointName := dp.Name
2024-05-20 19:40:19 +02:00
dp.Group = r.FormValue("group")
2024-04-30 08:04:16 +02:00
dp.Name = r.FormValue("name")
dp.Datatype = DatapointType(r.FormValue("datatype"))
2024-06-24 11:18:51 +02:00
dp.Comment = r.FormValue("comment")
2024-05-25 09:40:40 +02:00
dp.NodataProblemSeconds = nodataSeconds
2024-04-30 08:04:16 +02:00
err = dp.Update()
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-30 08:04:16 +02:00
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
}
}
}
2024-04-30 08:04:16 +02:00
w.Header().Add("Location", "/datapoints")
w.WriteHeader(302)
} // }}}
2024-06-02 10:59:06 +02:00
func actionDatapointDelete(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-05-02 08:59:55 +02:00
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-05-02 08:59:55 +02:00
return
}
err = DatapointDelete(id)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-05-02 08:59:55 +02:00
return
}
w.Header().Add("Location", "/datapoints")
w.WriteHeader(302)
} // }}}
2024-05-05 20:16:28 +02:00
func pageDatapointValues(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
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
}
2024-07-04 13:29:39 +02:00
// Manage the values from the timefilter component
2024-06-27 08:59:34 +02:00
var timeFrom, timeTo time.Time
2024-07-04 13:29:39 +02:00
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"),
)
2024-06-27 17:32:47 +02:00
2024-06-27 08:59:34 +02:00
// Fetch data point values according to the times.
2024-05-05 20:16:28 +02:00
var values []DatapointValue
2024-06-27 08:59:34 +02:00
values, err = DatapointValues(id, timeFrom, timeTo)
2024-05-05 20:16:28 +02:00
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
2024-07-04 13:29:39 +02:00
// GET parameters.
display := r.URL.Query().Get("display")
if display == "" && datapoint.Datatype == INT {
display = "graph"
}
2024-05-05 20:16:28 +02:00
page := Page{
LAYOUT: "main",
PAGE: "datapoint_values",
2024-06-25 08:59:07 +02:00
CONFIG: smonConfig.Settings,
2024-05-05 20:16:28 +02:00
MENU: "datapoints",
Icon: "datapoints",
2024-05-27 20:03:25 +02:00
Label: "Values for " + datapoint.Name,
2024-05-05 20:16:28 +02:00
}
page.Data = map[string]any{
"Datapoint": datapoint,
2024-05-27 20:03:25 +02:00
"Values": values,
2024-06-27 08:59:34 +02:00
"Display": display,
2024-07-04 13:29:39 +02:00
"TimeSubmit": "/datapoint/values/" + strconv.Itoa(datapoint.ID),
"TimeFrom": timeFrom.Format("2006-01-02T15:04:05"),
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
2024-05-05 20:16:28 +02:00
}
2024-06-27 09:51:52 +02:00
page.Render(w, r)
2024-05-05 20:16:28 +02:00
return
} // }}}
2024-06-27 16:53:30 +02:00
func actionDatapointJson(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
2024-07-04 13:29:39 +02:00
fromStr := r.URL.Query().Get("time-f")
2024-06-27 16:53:30 +02:00
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
}
2024-07-04 13:29:39 +02:00
toStr := r.URL.Query().Get("time-t")
2024-06-27 16:53:30 +02:00
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)
} // }}}
2024-05-01 10:02:33 +02:00
2024-06-27 09:51:52 +02:00
func pageTriggers(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-04-29 08:36:13 +02:00
areas, err := TriggersRetrieve()
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-29 08:36:13 +02:00
return
}
sort.SliceStable(areas, func(i, j int) bool {
return areas[i].Name < areas[j].Name
})
page := Page{
LAYOUT: "main",
PAGE: "triggers",
2024-06-25 08:59:07 +02:00
CONFIG: smonConfig.Settings,
2024-04-29 08:36:13 +02:00
Data: map[string]any{
"Areas": areas,
},
}
2024-06-27 09:51:52 +02:00
page.Render(w, r)
2024-04-29 08:36:13 +02:00
} // }}}
2024-06-02 10:59:06 +02:00
func actionTriggerCreate(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-05-30 15:06:41 +02:00
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 {
2024-06-01 09:18:56 +02:00
OK bool
2024-05-30 15:06:41 +02:00
Trigger Trigger
}{
true,
t,
}
j, _ := json.Marshal(resp)
w.Header().Add("Content-Type", "application/json")
w.Write(j)
} // }}}
2024-04-29 08:36:13 +02:00
func pageTriggerEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-29 08:36:13 +02:00
return
}
2024-05-01 10:02:33 +02:00
// Creating a new trigger uses the edit function.
// ID == 0 - create a new trigger.
// ID > 0 - edit existing trigger.
2024-04-29 08:36:13 +02:00
var trigger Trigger
2024-05-01 10:02:33 +02:00
if id > 0 {
trigger, err = TriggerRetrieve(id)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-05-01 10:02:33 +02:00
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 {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-05-01 10:02:33 +02:00
return
}
}
2024-04-29 08:36:13 +02:00
}
datapoints := make(map[string]Datapoint)
for _, dpname := range trigger.Datapoints {
2024-04-30 08:04:16 +02:00
dp, err := DatapointRetrieve(0, dpname)
2024-04-29 08:36:13 +02:00
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-29 08:36:13 +02:00
return
}
2024-05-01 10:02:33 +02:00
dp.LastDatapointValue.TemplateValue = dp.LastDatapointValue.Value()
2024-04-29 08:36:13 +02:00
datapoints[dpname] = dp
}
page := Page{
LAYOUT: "main",
PAGE: "trigger_edit",
MENU: "triggers",
2024-06-25 08:59:07 +02:00
CONFIG: smonConfig.Settings,
2024-04-29 08:36:13 +02:00
Label: "Trigger",
Icon: "triggers",
Data: map[string]any{
"Trigger": trigger,
"Datapoints": datapoints,
},
}
2024-06-27 09:51:52 +02:00
page.Render(w, r)
2024-04-29 08:36:13 +02:00
} // }}}
2024-06-02 10:59:06 +02:00
func actionTriggerDatapointAdd(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
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)
} // }}}
2024-06-02 10:59:06 +02:00
func actionTriggerUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-04-29 08:36:13 +02:00
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-29 08:36:13 +02:00
return
}
var trigger Trigger
2024-05-01 10:02:33 +02:00
if id > 0 {
trigger, err = TriggerRetrieve(id)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-05-01 10:02:33 +02:00
return
}
} else {
trigger.SectionID, err = strconv.Atoi(r.FormValue("sectionID"))
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-05-01 10:02:33 +02:00
return
}
2024-04-29 08:36:13 +02:00
}
trigger.Name = r.FormValue("name")
trigger.Expression = r.FormValue("expression")
2024-05-01 10:02:33 +02:00
trigger.Datapoints = r.Form["datapoints[]"]
2024-04-29 08:36:13 +02:00
err = trigger.Update()
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-29 08:36:13 +02:00
return
}
2024-04-30 08:04:16 +02:00
w.Header().Add("Location", "/triggers")
2024-04-29 08:36:13 +02:00
w.WriteHeader(302)
} // }}}
2024-06-02 10:59:06 +02:00
func actionTriggerRun(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-04-29 08:36:13 +02:00
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-29 08:36:13 +02:00
return
}
var trigger Trigger
trigger, err = TriggerRetrieve(id)
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-29 08:36:13 +02:00
return
}
expr, _ := io.ReadAll(r.Body)
trigger.Expression = string(expr)
resp := struct {
OK bool
Output any
}{
OK: true,
}
2024-04-30 20:10:05 +02:00
resp.Output, err = trigger.Run()
2024-04-29 08:36:13 +02:00
if err != nil {
2024-05-05 10:10:04 +02:00
werr.Wrap(err).Log()
2024-04-29 08:36:13 +02:00
httpError(w, err)
return
}
j, _ := json.Marshal(resp)
w.Header().Add("Content-Type", "application/json")
w.Write(j)
} // }}}
2024-06-01 09:18:56 +02:00
func actionTriggerDelete(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
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)
} // }}}
2024-05-01 10:02:33 +02:00
2024-06-27 09:51:52 +02:00
func pageConfiguration(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-04-29 08:36:13 +02:00
areas, err := AreaRetrieve()
if err != nil {
2024-05-05 10:10:04 +02:00
httpError(w, werr.Wrap(err).Log())
2024-04-29 08:36:13 +02:00
return
}
sort.SliceStable(areas, func(i, j int) bool {
return areas[i].Name < areas[j].Name
})
page := Page{
LAYOUT: "main",
PAGE: "configuration",
2024-06-25 08:59:07 +02:00
CONFIG: smonConfig.Settings,
2024-04-29 08:36:13 +02:00
Data: map[string]any{
2024-06-28 15:28:52 +02:00
"Areas": areas,
"NotificationServices": notificationManager.Services(),
"AvailableServices": notification.AvailableServices(),
2024-04-29 08:36:13 +02:00
},
}
2024-06-27 09:51:52 +02:00
page.Render(w, r)
2024-04-29 08:36:13 +02:00
} // }}}
2024-06-25 09:18:15 +02:00
func actionConfigurationTheme(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
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)
} // }}}
2024-06-27 10:02:11 +02:00
func actionConfigurationTimezone(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
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)
} // }}}
2024-06-28 15:28:52 +02:00
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.
2024-06-28 17:18:08 +02:00
service = notification.NewInstance(notificationType)
logger.Info("FOO", "service", fmt.Sprintf("%p %#v\n", service, service))
2024-06-28 15:28:52 +02:00
} 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 {
2024-06-28 17:18:08 +02:00
emptyService := notification.NewInstance(r.PostFormValue("type"))
2024-06-28 15:28:52 +02:00
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()
2024-06-28 15:28:52 +02:00
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}
2024-06-28 17:18:08 +02:00
func actionConfigurationNotificationDelete(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
}
DeleteNotificationService(prio)
notificationManager.RemoveService(prio)
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}
2024-06-29 15:20:31 +02:00
func pageNotifications(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
2024-06-29 19:30:26 +02:00
var err error
2024-07-04 13:29:39 +02:00
// Manage the values from the timefilter component
2024-06-29 19:30:26 +02:00
var timeFrom, timeTo time.Time
2024-07-04 13:29:39 +02:00
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"),
)
2024-06-29 19:30:26 +02:00
nss, err := notificationsSent(timeFrom, timeTo)
2024-06-29 15:20:31 +02:00
if err != nil {
pageError(w, "/", werr.Wrap(err).Log())
}
page := Page{
LAYOUT: "main",
PAGE: "notifications",
CONFIG: smonConfig.Settings,
Data: map[string]any{
"Notifications": nss,
2024-07-04 13:29:39 +02:00
"TimeSubmit": "/notifications",
2024-06-29 19:30:26 +02:00
"TimeFrom": timeFrom.Format("2006-01-02T15:04:05"),
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
2024-06-29 15:20:31 +02:00
},
}
page.Render(w, r)
} // }}}