package main import ( // External ws "git.gibonuddevalla.se/go/webservice" "git.gibonuddevalla.se/go/webservice/session" werr "git.gibonuddevalla.se/go/wrappederror" // Internal "smon/notification" // Standard "embed" "encoding/json" "flag" "fmt" "html/template" "io" "io/fs" "log/slog" "net/http" "os" "path" "sort" "strconv" "time" ) const VERSION = "v13" var ( logger *slog.Logger flagConfigFile string flagDev bool flagVersion bool service *ws.Service logFile *os.File parsedTemplates map[string]*template.Template componentFilenames []string notificationManager notification.Manager //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.yaml") flag.StringVar(&flagConfigFile, "config", cfgPath, "Path and filename of the YAML 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) } var err error werr.Init() werr.SetLogCallback(logHandler) service, err = ws.New(flagConfigFile, VERSION, logger) if err != nil { logger.Error("application", "error", err) os.Exit(1) } smonConf := SmonConfiguration{} j, _ := json.Marshal(service.Config.Application) json.Unmarshal(j, &smonConf) logFile, err = os.OpenFile(smonConf.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { logger.Error("application", "error", err) return } service.SetDatabase(sqlProvider) service.SetStaticFS(staticFS, "static") service.SetStaticDirectory("static", flagDev) service.InitDatabaseConnection() err = service.Db.Connect() 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) } service.Register("/", false, false, staticHandler) service.Register("/area/new/{name}", false, false, areaNew) service.Register("/area/rename/{id}/{name}", false, false, areaRename) service.Register("/section/new/{areaID}/{name}", false, false, sectionNew) service.Register("/section/rename/{id}/{name}", false, false, sectionRename) service.Register("/problems", false, false, pageProblems) service.Register("/problem/acknowledge/{id}", false, false, pageProblemAcknowledge) service.Register("/problem/unacknowledge/{id}", false, false, pageProblemUnacknowledge) service.Register("/datapoints", false, false, pageDatapoints) service.Register("/datapoint/edit/{id}", false, false, pageDatapointEdit) service.Register("/datapoint/update/{id}", false, false, pageDatapointUpdate) service.Register("/datapoint/delete/{id}", false, false, pageDatapointDelete) service.Register("/datapoint/values/{id}", false, false, pageDatapointValues) service.Register("/triggers", false, false, pageTriggers) service.Register("/trigger/edit/{id}", false, false, pageTriggerEdit) service.Register("/trigger/edit/{id}/{sectionID}", false, false, pageTriggerEdit) service.Register("/trigger/update/{id}", false, false, pageTriggerUpdate) service.Register("/trigger/run/{id}", false, false, pageTriggerRun) service.Register("/configuration", false, false, pageConfiguration) service.Register("/entry/{datapoint}", false, false, entryDatapoint) go nodataLoop() err = service.Start() 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 logHandler(err werr.Error) { // {{{ 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) } // }}} 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) } // }}} func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{ 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 = 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 `, (*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.Local().Format(`2006-01-02 15:04: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("main.gotmpl").Funcs(funcMap).ParseFS(os.DirFS("."), filenames...) } else { tmpl, err = template.New("main.gotmpl").Funcs(funcMap).ParseFS(viewFS, filenames...) } if err != nil { err = werr.Wrap(err).Log() return } parsedTemplates[page] = tmpl return } // }}} func pageIndex(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ page := Page{ LAYOUT: "main", PAGE: "index", } page.Render(w) } // }}} func areaNew(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ 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) return } // }}} func areaRename(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 } 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) return } // }}} func sectionNew(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ 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) return } // }}} func sectionRename(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 } 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) return } // }}} func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ page := Page{ LAYOUT: "main", PAGE: "problems", } problems, err := ProblemsRetrieve() 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, } page.Render(w) return } // }}} func pageProblemAcknowledge(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 = ProblemAcknowledge(id, true) if err != nil { httpError(w, werr.Wrap(err).Log()) return } w.Header().Add("Location", "/problems") w.WriteHeader(302) return } // }}} func pageProblemUnacknowledge(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 = ProblemAcknowledge(id, false) if err != nil { httpError(w, werr.Wrap(err).Log()) return } w.Header().Add("Location", "/problems") w.WriteHeader(302) return } // }}} func pageDatapoints(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ page := Page{ LAYOUT: "main", PAGE: "datapoints", } 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) return } // }}} func pageDatapointEdit(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 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 } } page := Page{ LAYOUT: "main", PAGE: "datapoint_edit", MENU: "datapoints", Icon: "datapoints", Label: "Datapoint", } page.Data = map[string]any{ "Datapoint": datapoint, } page.Render(w) return } // }}} func pageDatapointUpdate(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 nodataSeconds int nodataSeconds, _ = strconv.Atoi(r.FormValue("nodata_seconds")) var dp Datapoint dp.ID = id dp.Group = r.FormValue("group") dp.Name = r.FormValue("name") dp.Datatype = DatapointType(r.FormValue("datatype")) dp.NodataProblemSeconds = nodataSeconds err = dp.Update() if err != nil { httpError(w, werr.Wrap(err).Log()) return } w.Header().Add("Location", "/datapoints") w.WriteHeader(302) } // }}} func pageDatapointDelete(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 = 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, _ *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 } var values []DatapointValue values, err = DatapointValues(id) if err != nil { httpError(w, werr.Wrap(err).Log()) return } page := Page{ LAYOUT: "main", PAGE: "datapoint_values", MENU: "datapoints", Icon: "datapoints", Label: "Values for " + datapoint.Name, } page.Data = map[string]any{ "Datapoint": datapoint, "Values": values, } page.Render(w) return } // }}} func pageTriggers(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ 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", Data: map[string]any{ "Areas": areas, }, } page.Render(w) } // }}} func pageTriggerEdit(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 } // 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", Label: "Trigger", Icon: "triggers", Data: map[string]any{ "Trigger": trigger, "Datapoints": datapoints, }, } page.Render(w) } // }}} func pageTriggerUpdate(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 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 pageTriggerRun(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 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 pageConfiguration(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ 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", Data: map[string]any{ "Areas": areas, }, } page.Render(w) } // }}}