commit 89f483171aa9ac205dbd91028add7b21a8943db9 Author: Magnus Ă…hall Date: Mon Apr 29 08:36:13 2024 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2e2f141 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +smon +smon.exe diff --git a/area.go b/area.go new file mode 100644 index 0000000..0a2a642 --- /dev/null +++ b/area.go @@ -0,0 +1,62 @@ +package main + +import ( + // External + re "git.gibonuddevalla.se/go/wrappederror" + + // Standard + "encoding/json" + "sort" +) + +type Area struct { + ID int + Name string + + Sections []Section +} + +func AreaRetrieve() (areas []Area, err error) {// {{{ + areas = []Area{} + row := service.Db.Conn.QueryRow(` + SELECT + jsonb_agg(jsonsections) + FROM ( + SELECT + a.id, + a.name, + jsonb_agg( + jsonb_build_object( + 'id', s.id, + 'name', s.name + ) + ) AS sections + FROM area a + INNER JOIN section s ON s.area_id = a.id + GROUP BY + a.id, a.name + ) jsonsections`, + ) + + var jsonData []byte + err = row.Scan(&jsonData) + if err != nil { + err = re.Wrap(err) + return + } + + err = json.Unmarshal(jsonData, &areas) + if err != nil { + err = re.Wrap(err) + return + } + + return +}// }}} + +func (a Area) SortedSections() []Section {// {{{ + sort.SliceStable(a.Sections, func (i, j int) bool { + return a.Sections[i].Name < a.Sections[j].Name + }) + return a.Sections +}// }}} diff --git a/config.go b/config.go new file mode 100644 index 0000000..150fa1d --- /dev/null +++ b/config.go @@ -0,0 +1,5 @@ +package main + +type SmonConfiguration struct { + LogFile string +} diff --git a/datapoint.go b/datapoint.go new file mode 100644 index 0000000..845f744 --- /dev/null +++ b/datapoint.go @@ -0,0 +1,112 @@ +package main + +import ( + // External + we "git.gibonuddevalla.se/go/wrappederror" + + // Standard + "database/sql" + "time" +) + +type DatapointType string + +const ( + INT DatapointType = "INT" + STRING = "STRING" + DATETIME = "DATETIME" +) + +type Datapoint struct { + ID int + Name string + SectionID int `db:"section_id"` + Datatype DatapointType + LastValue time.Time `db:"last_value"` + DatapointValueJSON []byte `db:"datapoint_value_json"` + LastDatapointValue DatapointValue +} + +type DatapointValue struct { + ID int + DatapointID int `db:"datapoint_id"` + Ts time.Time + ValueInt sql.NullInt64 `db:"value_int"` + ValueString sql.NullString `db:"value_string"` + ValueDateTime sql.NullTime `db:"value_datetime"` +} + +func (dp DatapointValue) Value() any { + if dp.ValueInt.Valid { + return dp.ValueInt.Int64 + } + if dp.ValueString.Valid { + return dp.ValueString.String + } + if dp.ValueDateTime.Valid { + return dp.ValueDateTime.Time + } + return nil +} + +func DatapointAdd[T any](name string, value T) (err error) { + row := service.Db.Conn.QueryRow(`SELECT id, datatype FROM datapoint WHERE name=$1`, name) + + var dpID int + var dpType DatapointType + + err = row.Scan(&dpID, &dpType) + if err != nil { + err = we.Wrap(err).WithData(struct { + Name string + Value any + }{name, value}) + return + } + + switch dpType { + case INT: + _, err = service.Db.Conn.Exec(`INSERT INTO datapoint_value(datapoint_id, value_int) VALUES($1, $2)`, dpID, value) + case STRING: + _, err = service.Db.Conn.Exec(`INSERT INTO datapoint_value(datapoint_id, value_string) VALUES($1, $2)`, dpID, value) + case DATETIME: + _, err = service.Db.Conn.Exec(`INSERT INTO datapoint_value(datapoint_id, value_datetime) VALUES($1, $2)`, dpID, value) + } + if err != nil { + err = we.Wrap(err).WithData(struct { + ID int + value any + }{dpID, value}) + return + } + + return +} + +func DatapointRetrieve(name string) (dp Datapoint, err error) { + row := service.Db.Conn.QueryRowx( + `SELECT * FROM datapoint dp WHERE dp.name = $1`, + name, + ) + err = row.StructScan(&dp) + if err != nil { + err = we.Wrap(err).WithData(name) + return + } + + row = service.Db.Conn.QueryRowx(` + SELECT * + FROM datapoint_value + WHERE datapoint_id = $1 + ORDER BY ts DESC + LIMIT 1 + `, + dp.ID, + ) + err = row.StructScan(&dp.LastDatapointValue) + if err != nil { + err = we.Wrap(err).WithData(dp.ID) + return + } + return +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..3626f2e --- /dev/null +++ b/go.mod @@ -0,0 +1,19 @@ +module smon + +go 1.22.0 + +require ( + git.gibonuddevalla.se/go/webservice v0.2.12 + git.gibonuddevalla.se/go/wrappederror v0.3.4 + github.com/expr-lang/expr v1.16.5 +) + +require ( + git.gibonuddevalla.se/go/dbschema v1.3.0 // indirect + github.com/google/uuid v1.5.0 // indirect + github.com/gorilla/websocket v1.5.1 // indirect + github.com/jmoiron/sqlx v1.3.5 // indirect + github.com/lib/pq v1.10.9 // indirect + golang.org/x/net v0.17.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..26c1b8f --- /dev/null +++ b/go.sum @@ -0,0 +1,33 @@ +git.gibonuddevalla.se/go/dbschema v1.3.0 h1:HzFMR29tWfy/ibIjltTbIMI4inVktj/rh8bESALibgM= +git.gibonuddevalla.se/go/dbschema v1.3.0/go.mod h1:BNw3q/574nXbGoeWyK+tLhRfggVkw2j2aXZzrBKC3ig= +git.gibonuddevalla.se/go/webservice v0.2.12 h1:IcaIycmF7eO88RmFQkslHaKRWYxXdciVQXUAvJ36b4g= +git.gibonuddevalla.se/go/webservice v0.2.12/go.mod h1:3uBS6nLbK9qbuGzDls8MZD5Xr9ORY1Srbj6v06BIhws= +git.gibonuddevalla.se/go/wrappederror v0.3.4 h1:dcKp9/+QrZSO3S4fVnq7yG2p7DUZVmlztBAb/OzoZNY= +git.gibonuddevalla.se/go/wrappederror v0.3.4/go.mod h1:j4w320Hk1wvhOPjUaK4GgLvmtnjUUM5yVu6JFO1OCSc= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/expr-lang/expr v1.16.5 h1:m2hvtguFeVaVNTHj8L7BoAyt7O0PAIBaSVbjdHgRXMs= +github.com/expr-lang/expr v1.16.5/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= +github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= +github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU= +github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY= +github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY= +github.com/jmoiron/sqlx v1.3.5 h1:vFFPA71p1o5gAeqtEAwLU4dnX2napprKtHr7PYIcN3g= +github.com/jmoiron/sqlx v1.3.5/go.mod h1:nRVWtLre0KfCLJvgxzCsLVMogSvQ1zNJtpYr2Ccp0mQ= +github.com/lib/pq v1.2.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= +github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..a129a72 --- /dev/null +++ b/main.go @@ -0,0 +1,391 @@ +package main + +import ( + // External + ws "git.gibonuddevalla.se/go/webservice" + "git.gibonuddevalla.se/go/webservice/session" + we "git.gibonuddevalla.se/go/wrappederror" + + // Standard + "embed" + "encoding/json" + "flag" + "fmt" + "html/template" + "io" + "io/fs" + "log/slog" + "net/http" + "os" + "path" + "sort" + "strconv" +) + +const VERSION = "v1" + +var ( + logger *slog.Logger + flagConfigFile string + flagDev bool + service *ws.Service + logFile *os.File + parsedTemplates map[string]*template.Template + componentFilenames []string + + //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", we.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.Parse() + + componentFilenames, err = getComponentFilenames(viewFS) + if err != nil { + logger.Error("application", "error", err) + os.Exit(1) + } + parsedTemplates = make(map[string]*template.Template) +} // }}} + +func main() { // {{{ + var err error + + we.Init() + we.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.Register("/", false, false, staticHandler) + service.Register("/problems", false, false, pageProblems) + service.Register("/triggers", false, false, pageTriggers) + service.Register("/trigger/edit/{id}", 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) + + err = service.Start() + if err != nil { + logger.Error("webserver", "error", we.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 we.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, we.Wrap(err).Log()) + return + } + + 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 + } + + filenames := []string{layoutFilename, pageFilename} + filenames = append(filenames, componentFilenames...) + logger.Info("template", "op", "parse", "layout", layout, "page", page, "filenames", filenames) + if flagDev { + parsedTemplates[page], err = template.ParseFS(os.DirFS("."), filenames...) + } else { + parsedTemplates[page], err = template.ParseFS(viewFS, filenames...) + } + tmpl = parsedTemplates[page] + + if err != nil { + err = we.Wrap(err).Log() + return + } + + return +} // }}} + +func pageIndex(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ + page := Page{ + LAYOUT: "main", + PAGE: "index", + } + page.Render(w) +} // }}} +func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ + page := Page{ + LAYOUT: "main", + PAGE: "problems", + } + + /* + areas, err := ProblemsRetrieve() + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + sort.SliceStable(areas, func(i, j int) bool { + return areas[i].Name < areas[j].Name + }) + + logger.Info("problems", "areas", areas) + */ + + page.Data = map[string]any{ + //"Areas": areas, + } + page.Render(w) + return +} // }}} +func pageTriggers(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ + areas, err := TriggersRetrieve() + if err != nil { + httpError(w, we.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, we.Wrap(err).Log()) + return + } + + var trigger Trigger + trigger, err = TriggerRetrieve(id) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + + datapoints := make(map[string]Datapoint) + for _, dpname := range trigger.Datapoints { + dp, err := DatapointRetrieve(dpname) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + 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, we.Wrap(err).Log()) + return + } + + var trigger Trigger + trigger, err = TriggerRetrieve(id) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + + trigger.Name = r.FormValue("name") + trigger.Expression = r.FormValue("expression") + err = trigger.Update() + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + + w.Header().Add("Location", fmt.Sprintf("/trigger/edit/%d", id)) + 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, we.Wrap(err).Log()) + return + } + + var trigger Trigger + trigger, err = TriggerRetrieve(id) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + + expr, _ := io.ReadAll(r.Body) + trigger.Expression = string(expr) + + datapoints := make(map[string]Datapoint) + for _, dpname := range trigger.Datapoints { + dp, err := DatapointRetrieve(dpname) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + datapoints[dpname] = dp + } + + resp := struct { + OK bool + Output any + }{ + OK: true, + } + resp.Output, err = trigger.Run(datapoints) + if err != nil { + we.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, we.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) +} // }}} diff --git a/page.go b/page.go new file mode 100644 index 0000000..2465645 --- /dev/null +++ b/page.go @@ -0,0 +1,59 @@ +package main + +import ( + // External + we "git.gibonuddevalla.se/go/wrappederror" + + // Standard + "fmt" + "net/http" + "unicode" + "unicode/utf8" +) + +type Page struct { + LAYOUT string + PAGE string + MENU string + Label string + Icon string + + Data any +} + +func (p *Page) Render(w http.ResponseWriter) { + tmpl, err := getPage(p.LAYOUT, p.PAGE) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + + if p.Icon == "" { + p.Icon = p.PAGE + } + + if p.Label == "" { + r, _ := utf8.DecodeRuneInString(p.PAGE) + titled := unicode.ToTitle(r) + p.Label = fmt.Sprintf("%s%s", string(titled), p.PAGE[1:len(p.PAGE)]) + } + + if p.MENU == "" { + p.MENU = p.PAGE + } + + data := map[string]any{ + "VERSION": VERSION, + "LAYOUT": p.LAYOUT, + "PAGE": p.PAGE, + "MENU": p.MENU, + "Label": p.Label, + "Icon": p.Icon, + "Data": p.Data, + } + + err = tmpl.Execute(w, data) + if err != nil { + httpError(w, we.Wrap(err).Log()) + } +} diff --git a/problem.go b/problem.go new file mode 100644 index 0000000..4c7b00e --- /dev/null +++ b/problem.go @@ -0,0 +1,20 @@ +package main + +import ( + // External + // we "git.gibonuddevalla.se/go/wrappederror" + + // Standard + // "encoding/json" +) + +/* +type Problem struct { + ID int + Name string + SectionID int + Expression string + DatapointNames []string +} +*/ + diff --git a/section.go b/section.go new file mode 100644 index 0000000..2cf9193 --- /dev/null +++ b/section.go @@ -0,0 +1,23 @@ +package main + +import ( + // Standard + "sort" +) + +type Section struct { + ID int + Name string + AreaID int `db:"area_id"` + Area Area + + Triggers []Trigger +} + +func (s *Section) SortedTriggers() []Trigger { + sort.SliceStable(s.Triggers, func(i, j int) bool { + return s.Triggers[i].Name < s.Triggers[j].Name + }) + + return s.Triggers +} diff --git a/sql/00001.sql b/sql/00001.sql new file mode 100644 index 0000000..3361b4a --- /dev/null +++ b/sql/00001.sql @@ -0,0 +1,10 @@ +CREATE TYPE datapoint_type AS ENUM('INT', 'STRING', 'DATETIME'); + +CREATE TABLE public.datapoint ( + "id" serial NOT NULL, + "name" varchar NOT NULL, + "datatype" datapoint_type NOT NULL, + "last_value" timestamptz DEFAULT '1970-01-01 00:00:00+00' NOT NULL, + CONSTRAINT datapoint_pk PRIMARY KEY (id), + CONSTRAINT datapoint_unique UNIQUE ("name") +); diff --git a/sql/00002.sql b/sql/00002.sql new file mode 100644 index 0000000..ab8de8e --- /dev/null +++ b/sql/00002.sql @@ -0,0 +1,10 @@ +CREATE TABLE public.datapoint_value ( + id serial8 NOT NULL, + datapoint_id int4 NOT NULL, + ts timestamptz DEFAULT NOW() NOT NULL, + value_int int8 NULL, + value_string varchar NULL, + value_datetime timestamptz NULL, + CONSTRAINT datapoint_value_pk PRIMARY KEY (id), + CONSTRAINT datapoint_value_datapoint_fk FOREIGN KEY (datapoint_id) REFERENCES public.datapoint(id) ON DELETE CASCADE ON UPDATE CASCADE +); diff --git a/sql/00003.sql b/sql/00003.sql new file mode 100644 index 0000000..e992ee3 --- /dev/null +++ b/sql/00003.sql @@ -0,0 +1,13 @@ +CREATE TABLE public.area ( + id serial NOT NULL, + name varchar NOT NULL, + CONSTRAINT area_pk PRIMARY KEY (id) +); + +CREATE TABLE public.section ( + id serial NOT NULL, + name varchar NOT NULL, + area_id int4 NOT NULL, + CONSTRAINT section_pk PRIMARY KEY (id), + CONSTRAINT section_area_fk FOREIGN KEY (area_id) REFERENCES public.area(id) ON DELETE RESTRICT ON UPDATE RESTRICT +); diff --git a/sql/00004.sql b/sql/00004.sql new file mode 100644 index 0000000..ccb3d29 --- /dev/null +++ b/sql/00004.sql @@ -0,0 +1,2 @@ +ALTER TABLE public.datapoint ADD section_id int4 NULL; +ALTER TABLE public.datapoint ADD CONSTRAINT datapoint_section_fk FOREIGN KEY (section_id) REFERENCES public."section"(id) ON DELETE SET NULL ON UPDATE SET NULL; diff --git a/sql/00005.sql b/sql/00005.sql new file mode 100644 index 0000000..b7c1dec --- /dev/null +++ b/sql/00005.sql @@ -0,0 +1,11 @@ +CREATE TABLE public.trigger ( + id serial NOT NULL, + name varchar NOT NULL, + section_id int4 NOT NULL, + + expression varchar NOT NULL, + datapoints jsonb NOT NULL DEFAULT '[]', + + CONSTRAINT trigger_pk PRIMARY KEY (id), + CONSTRAINT trigger_section_fk FOREIGN KEY (section_id) REFERENCES public.section(id) ON DELETE RESTRICT ON UPDATE RESTRICT +); diff --git a/static/css/main.css b/static/css/main.css new file mode 100644 index 0000000..6cf8aa2 --- /dev/null +++ b/static/css/main.css @@ -0,0 +1,143 @@ +html { + box-sizing: border-box; +} +*, +*:before, +*:after { + box-sizing: inherit; +} +*:focus { + outline: none; +} +[onClick] { + cursor: pointer; +} +html, +body { + margin: 0; + padding: 0; +} +body { + background: #282828; + font-family: "Roboto", sans-serif; + color: #d5c4a1; + font-size: 11pt; +} +h1, +h2 { + margin-top: 0px; + margin-bottom: 4px; +} +h1 { + font-size: 1.5em; + color: #fb4934; +} +h2 { + font-size: 1.25em; +} +.roboto-light { + font-family: "Roboto", sans-serif; + font-weight: 300; + font-style: normal; +} +.roboto-medium { + font-family: "Roboto", sans-serif; + font-weight: 500; + font-style: normal; +} +input[type="text"], +textarea { + font-family: "Roboto Mono", monospace; + background: #202020; + color: #d5c4a1; + padding: 4px 8px; + border: none; + font-size: 1em; +} +button { + background: #202020; + color: #d5c4a1; + padding: 8px 32px; + border: 1px solid #3a3a3a; + font-size: 1em; + height: 3em; +} +#layout { + display: grid; + grid-template-areas: "menu content"; + grid-template-columns: 64px 1fr; + grid-template-rows: 100% 100%; + height: 100vh; +} +#menu { + display: flex; + flex-flow: column wrap; + justify-content: flex-start; + gap: 24px; + grid-area: menu; + background: #202020; + padding: 16px; +} +#menu img { + display: block; + width: 32px; +} +#page { + grid-area: content; + padding: 32px; +} +#page .page-label { + display: grid; + grid-template-columns: min-content 1fr; + grid-gap: 12px; + align-items: center; + margin-bottom: 32px; +} +#page .page-label div { + font-size: 1.5em; + color: #fb4934; +} +#page .page-label img { + display: block; +} +#areas { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 16px; +} +#areas .area { + background: #333; + border-radius: 4px; +} +#areas .area > .name { + background: #fb4934; + color: #fff; + font-weight: bold; + padding: 4px 16px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; +} +#areas .area .section { + margin: 8px 16px; +} +#areas .area .section > .name { + font-weight: bold; +} +#areas .area .section .triggers a { + color: inherit; + text-decoration: none; +} +#areas .area .section .triggers .trigger { + display: grid; + grid-template-columns: min-content 1fr; + grid-gap: 8px; + align-items: center; + margin-top: 8px; +} +#areas .area .section .triggers .trigger img { + height: 16px; +} +#areas .area .section .triggers .trigger .label { + color: #f7edd7; +} diff --git a/static/css/theme.css b/static/css/theme.css new file mode 100644 index 0000000..3d76859 --- /dev/null +++ b/static/css/theme.css @@ -0,0 +1,64 @@ +html { + box-sizing: border-box; +} +*, +*:before, +*:after { + box-sizing: inherit; +} +*:focus { + outline: none; +} +[onClick] { + cursor: pointer; +} +html, +body { + margin: 0; + padding: 0; +} +body { + background: #282828; + font-family: "Roboto", sans-serif; + color: #d5c4a1; + font-size: 11pt; +} +h1, +h2 { + margin-top: 0px; + margin-bottom: 4px; +} +h1 { + font-size: 1.5em; + color: #fb4934; +} +h2 { + font-size: 1.25em; +} +.roboto-light { + font-family: "Roboto", sans-serif; + font-weight: 300; + font-style: normal; +} +.roboto-medium { + font-family: "Roboto", sans-serif; + font-weight: 500; + font-style: normal; +} +input[type="text"], +textarea { + font-family: "Roboto Mono", monospace; + background: #202020; + color: #d5c4a1; + padding: 4px 8px; + border: none; + font-size: 1em; +} +button { + background: #202020; + color: #d5c4a1; + padding: 8px 32px; + border: 1px solid #3a3a3a; + font-size: 1em; + height: 3em; +} diff --git a/static/css/trigger_edit.css b/static/css/trigger_edit.css new file mode 100644 index 0000000..2df4fcd --- /dev/null +++ b/static/css/trigger_edit.css @@ -0,0 +1,101 @@ +html { + box-sizing: border-box; +} +*, +*:before, +*:after { + box-sizing: inherit; +} +*:focus { + outline: none; +} +[onClick] { + cursor: pointer; +} +html, +body { + margin: 0; + padding: 0; +} +body { + background: #282828; + font-family: "Roboto", sans-serif; + color: #d5c4a1; + font-size: 11pt; +} +h1, +h2 { + margin-top: 0px; + margin-bottom: 4px; +} +h1 { + font-size: 1.5em; + color: #fb4934; +} +h2 { + font-size: 1.25em; +} +.roboto-light { + font-family: "Roboto", sans-serif; + font-weight: 300; + font-style: normal; +} +.roboto-medium { + font-family: "Roboto", sans-serif; + font-weight: 500; + font-style: normal; +} +input[type="text"], +textarea { + font-family: "Roboto Mono", monospace; + background: #202020; + color: #d5c4a1; + padding: 4px 8px; + border: none; + font-size: 1em; +} +button { + background: #202020; + color: #d5c4a1; + padding: 8px 32px; + border: 1px solid #3a3a3a; + font-size: 1em; + height: 3em; +} +.widgets { + display: grid; + grid-template-columns: min-content 1fr; + gap: 8px 16px; +} +.widgets .label { + margin-top: 4px; +} +.widgets input[type="text"], +.widgets textarea { + width: 100%; +} +.widgets .datapoints { + font: "Roboto Mono", monospace; + display: grid; + grid-template-columns: min-content 1fr; + gap: 6px 8px; + margin-bottom: 8px; +} +.widgets .action { + display: grid; + grid-template-columns: min-content min-content 1fr; + grid-gap: 8px; +} +.widgets .action #run-result { + font-family: 'Roboto Mono', monospace; + margin-left: 16px; + padding: 16px; + background: #202020; + min-height: 8em; +} +.widgets .action #run-result.ok { + color: #d5c4a1; +} +.widgets .action #run-result.error { + color: #fb4934; +} diff --git a/static/images/configuration.svg b/static/images/configuration.svg new file mode 100644 index 0000000..e8de499 --- /dev/null +++ b/static/images/configuration.svg @@ -0,0 +1,69 @@ + + + + + + + + + + image/svg+xml + + + + + + cog + cog-outline + + + diff --git a/static/images/configuration_selected.svg b/static/images/configuration_selected.svg new file mode 100644 index 0000000..81e4124 --- /dev/null +++ b/static/images/configuration_selected.svg @@ -0,0 +1,67 @@ + + + + + + + + + + image/svg+xml + + + + + + cog + + + diff --git a/static/images/logo.svg b/static/images/logo.svg new file mode 100644 index 0000000..30d4d2b --- /dev/null +++ b/static/images/logo.svg @@ -0,0 +1,73 @@ + + + + + + + + + + image/svg+xml + + + + + + alert-octagram + alert-octagram-outline + database-alert + database-alert-outline + + + diff --git a/static/images/logo_selected.svg b/static/images/logo_selected.svg new file mode 100644 index 0000000..bc41249 --- /dev/null +++ b/static/images/logo_selected.svg @@ -0,0 +1,71 @@ + + + + + + + + + + image/svg+xml + + + + + + alert-octagram + alert-octagram-outline + database-alert + + + diff --git a/static/images/problems.svg b/static/images/problems.svg new file mode 100644 index 0000000..0194aff --- /dev/null +++ b/static/images/problems.svg @@ -0,0 +1,69 @@ + + + + + + + + + + image/svg+xml + + + + + + alert-octagram + alert-octagram-outline + + + diff --git a/static/images/problems_selected.svg b/static/images/problems_selected.svg new file mode 100644 index 0000000..1eea62f --- /dev/null +++ b/static/images/problems_selected.svg @@ -0,0 +1,67 @@ + + + + + + + + + + image/svg+xml + + + + + + alert-octagram + + + diff --git a/static/images/triggers.svg b/static/images/triggers.svg new file mode 100644 index 0000000..bbf749b --- /dev/null +++ b/static/images/triggers.svg @@ -0,0 +1,69 @@ + + + + + + + + + + image/svg+xml + + + + + + script-text + script-text-outline + + + diff --git a/static/images/triggers_selected.svg b/static/images/triggers_selected.svg new file mode 100644 index 0000000..bd59427 --- /dev/null +++ b/static/images/triggers_selected.svg @@ -0,0 +1,67 @@ + + + + + + + + + + image/svg+xml + + + + + + script-text + + + diff --git a/static/js/trigger_edit.mjs b/static/js/trigger_edit.mjs new file mode 100644 index 0000000..7446699 --- /dev/null +++ b/static/js/trigger_edit.mjs @@ -0,0 +1,53 @@ +export class UI { + constructor() { + document.getElementById('button-run'). + addEventListener('click', evt=>evt.preventDefault()) + + document.addEventListener('keydown', evt=>this.keyHandler(evt)) + } + setTrigger(t) { + this.trigger = t + } + run() { + this.trigger.run() + } + keyHandler(evt) { + if (evt.altKey && evt.shiftKey && evt.key == 'R') { + evt.preventDefault() + evt.stopPropagation() + this.run() + } + } +} + +export class Trigger { + constructor(id, name) { + this.id = id + this.name = name + } + run() { + const result = document.getElementById('run-result') + const classes = result.classList + const expr = document.getElementById('expr').value + + fetch(`/trigger/run/${this.id}`, { + method: 'POST', + cache: 'no-cache', + body: expr, + }) + .then(data => data.json()) + .then(json => { + if (!json.OK) { + classes.remove('ok') + classes.add('error') + result.innerText = json.Error + return + } + + classes.remove('error') + classes.add('ok') + result.innerText = json.Output + }) + .catch(err => alert(err)) + } +} diff --git a/static/less/Makefile b/static/less/Makefile new file mode 100644 index 0000000..7e91a05 --- /dev/null +++ b/static/less/Makefile @@ -0,0 +1,11 @@ +less = $(wildcard *.less) +_css = $(less:.less=.css) +css = $(addprefix ../css/, $(_css) ) + +../css/%.css: %.less theme.less + lessc $< ../css/$@ + +all: $(css) + +clean: + rm -vf $(css) diff --git a/static/less/loop_make.sh b/static/less/loop_make.sh new file mode 100755 index 0000000..0aceed7 --- /dev/null +++ b/static/less/loop_make.sh @@ -0,0 +1,15 @@ +#!/bin/bash + +while true +do + # inotifywait -q -e MOVE_SELF *less + inotifywait -q -e MODIFY *less + #sleep 0.5 + clear + if make -j12; then + echo -e "\n\e[32;1mOK!\e[0m" + #curl -s http://notes.lan:1371/_ws/css_update + sleep 1 + clear + fi +done diff --git a/static/less/main.less b/static/less/main.less new file mode 100644 index 0000000..0a3b18f --- /dev/null +++ b/static/less/main.less @@ -0,0 +1,102 @@ +@import "theme.less"; + +#layout { + display: grid; + grid-template-areas: "menu content"; + grid-template-columns: 64px 1fr; + grid-template-rows: + 100% 100%; + height: 100vh; +} + +#menu { + display: flex; + flex-flow: column wrap; + justify-content: flex-start; + gap: 24px; + + grid-area: menu; + background: @bg2; + padding: 16px; + + img { + display: block; + width: 32px; + } +} + +#page { + grid-area: content; + padding: 32px; + + .page-label { + display: grid; + grid-template-columns: min-content 1fr; + grid-gap: 12px; + align-items: center; + margin-bottom: 32px; + + div { + font-size: 1.5em; + color: @color1; + } + + img { + display: block; + } + } +} + + +#areas { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-top: 16px; + + .area { + background: @bg3; + border-radius: 4px; + + &>.name { + background: @color1; + color: #fff; + font-weight: bold; + padding: 4px 16px; + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } + + .section { + margin: 8px 16px; + + &>.name { + font-weight: bold; + } + + .triggers { + + a { + color: inherit; + text-decoration: none; + } + + .trigger { + display: grid; + grid-template-columns: min-content 1fr; + grid-gap: 8px; + align-items: center; + margin-top: 8px; + + img { + height: 16px; + } + + .label { + color: @text2; + } + } + } + } + } +} diff --git a/static/less/theme.less b/static/less/theme.less new file mode 100644 index 0000000..fa5699a --- /dev/null +++ b/static/less/theme.less @@ -0,0 +1,82 @@ +@bg1: #282828; +@bg2: #202020; +@bg3: #333; + +@text1: #d5c4a1; +@text2: #f7edd7; + +@error: #fb4934; +@color1: #fb4934; + +html { + box-sizing: border-box; +} + +*, *:before, *:after { + box-sizing: inherit; +} + +*:focus { + outline: none; +} + +[onClick] { + cursor: pointer; +} + +html, +body { + margin: 0; + padding: 0; +} + +body { + background: @bg1; + font-family: "Roboto", sans-serif; + color: @text1; + font-size: 11pt; +} + +h1, h2 { + margin-top: 0px; + margin-bottom: 4px; +} + +h1 { + font-size: 1.5em; + color: @color1; +} + +h2 { + font-size: 1.25em; +} + +.roboto-light { + font-family: "Roboto", sans-serif; + font-weight: 300; + font-style: normal; +} + +.roboto-medium { + font-family: "Roboto", sans-serif; + font-weight: 500; + font-style: normal; +} + +input[type="text"], textarea { + font-family: "Roboto Mono", monospace; + background: @bg2; + color: @text1; + padding: 4px 8px; + border: none; + font-size: 1em; +} + +button { + background: @bg2; + color: @text1; + padding: 8px 32px; + border: 1px solid lighten(@bg2, 10%); + font-size: 1em; + height: 3em; +} diff --git a/static/less/trigger_edit.less b/static/less/trigger_edit.less new file mode 100644 index 0000000..94dcf4e --- /dev/null +++ b/static/less/trigger_edit.less @@ -0,0 +1,45 @@ +@import "theme.less"; + +.widgets { + display: grid; + grid-template-columns: min-content 1fr; + gap: 8px 16px; + + .label { + margin-top: 4px; + } + + input[type="text"], textarea { + width: 100%; + } + + .datapoints { + font: "Roboto Mono", monospace; + display: grid; + grid-template-columns: min-content 1fr; + gap: 6px 8px; + margin-bottom: 8px; + } + + .action { + display: grid; + grid-template-columns: min-content min-content 1fr; + grid-gap: 8px; + + #run-result { + font-family: 'Roboto Mono', monospace; + margin-left: 16px; + padding: 16px; + background: @bg2; + min-height: 8em; + + &.ok { + color: @text1; + } + + &.error { + color: @error; + } + } + } +} diff --git a/trigger.go b/trigger.go new file mode 100644 index 0000000..f472a99 --- /dev/null +++ b/trigger.go @@ -0,0 +1,143 @@ +package main + +import ( + // External + we "git.gibonuddevalla.se/go/wrappederror" + "github.com/expr-lang/expr" + + // Standard + "encoding/json" + "fmt" + "strings" +) + +type Trigger struct { + ID int + Name string + SectionID int `db:"section_id"` + Expression string + Datapoints []string +} + +func Foo() { +} + +func TriggersRetrieve() (areas []Area, err error) { // {{{ + areas = []Area{} + + row := service.Db.Conn.QueryRow(` + WITH section_triggers AS ( + SELECT + s.id AS id, + s.area_id, + s.name AS name, + jsonb_agg( + to_jsonb(t.*) + ) AS triggers + FROM section s + LEFT JOIN "trigger" t ON t.section_id = s.id + GROUP BY + s.id, s.name) + + SELECT + jsonb_agg(jsonsections) + FROM ( + SELECT + a.id, + a.name, + jsonb_agg( + to_jsonb( + s.* + ) + ) AS sections + FROM area a + LEFT JOIN section_triggers s ON s.area_id = a.id + GROUP BY + a.id, a.name + ) jsonsections + `, + ) + + var jsonData []byte + err = row.Scan(&jsonData) + if err != nil { + err = we.Wrap(err) + return + } + + err = json.Unmarshal(jsonData, &areas) + if err != nil { + err = we.Wrap(err) + return + } + + return +} // }}} +func TriggerRetrieve(id int) (trigger Trigger, err error) { // {{{ + row := service.Db.Conn.QueryRow(`SELECT to_jsonb(t.*) FROM "trigger" t WHERE id=$1`, id) + var jsonData []byte + err = row.Scan(&jsonData) + if err != nil { + err = we.Wrap(err) + return + } + + err = json.Unmarshal(jsonData, &trigger) + return +} // }}} +func (t *Trigger) Validate() (ok bool, err error) { + if strings.TrimSpace(t.Name) == "" { + err = fmt.Errorf("Name can't be empty") + return + } + + if strings.TrimSpace(t.Expression) == "" { + err = fmt.Errorf("Expression can't be empty") + return + } + + return true, nil +} +func (t *Trigger) Update() (err error) { + var ok bool + if ok, err = t.Validate(); !ok { + return + } + + logger.Info("FOO", "trigger", t) + _, err = service.Db.Conn.Exec(` + UPDATE "trigger" + SET + name=$2, + expression=$3 + WHERE + id=$1 + `, + t.ID, + t.Name, + t.Expression, + ) + if err != nil { + err = we.Wrap(err) + } + return +} + +func (t *Trigger) Run(datapoints map[string]Datapoint) (output any, err error) { + env := make(map[string]any) + for dpName, dp := range datapoints { + env[dpName] = dp.LastDatapointValue.Value() + } + + program, err := expr.Compile(t.Expression) + if err != nil { + return + } + + output, err = expr.Run(program, env) + if err != nil { + return + } + + return +} diff --git a/views/components/head_fonts.gotmpl b/views/components/head_fonts.gotmpl new file mode 100644 index 0000000..18be2a7 --- /dev/null +++ b/views/components/head_fonts.gotmpl @@ -0,0 +1,6 @@ +{{ define "fonts" }} + + + + +{{ end }} diff --git a/views/components/menu.gotmpl b/views/components/menu.gotmpl new file mode 100644 index 0000000..14722eb --- /dev/null +++ b/views/components/menu.gotmpl @@ -0,0 +1,8 @@ +{{ define "menu" }} + +{{ end }} diff --git a/views/components/page_label.gotmpl b/views/components/page_label.gotmpl new file mode 100644 index 0000000..7d8c261 --- /dev/null +++ b/views/components/page_label.gotmpl @@ -0,0 +1,6 @@ +{{ define "page_label" }} +
+ +
{{ .Label }}
+
+{{ end }} diff --git a/views/layouts/main.gotmpl b/views/layouts/main.gotmpl new file mode 100644 index 0000000..741061b --- /dev/null +++ b/views/layouts/main.gotmpl @@ -0,0 +1,17 @@ + + + + + + {{ template "fonts" }} + + + +
+ {{ block "menu" . }}{{ end }} +
+ {{ block "page" . }}{{ end }} +
+
+ + diff --git a/views/pages/area_new.gotmpl b/views/pages/area_new.gotmpl new file mode 100644 index 0000000..23e055d --- /dev/null +++ b/views/pages/area_new.gotmpl @@ -0,0 +1,3 @@ +{{ define "page" }} +area new +{{ end }} diff --git a/views/pages/configuration.gotmpl b/views/pages/configuration.gotmpl new file mode 100644 index 0000000..b4e8601 --- /dev/null +++ b/views/pages/configuration.gotmpl @@ -0,0 +1,20 @@ +{{ define "page" }} + + {{ block "page_label" . }}{{end}} + +

Areas

+ +
+ {{ range .Data.Areas }} +
+
{{ .Name }}
+ {{ range .SortedSections }} +
+
{{ .Name }}
+
+ {{ end }} +
+ {{ end }} +
+ +{{ end }} diff --git a/views/pages/index.gotmpl b/views/pages/index.gotmpl new file mode 100644 index 0000000..7e6e275 --- /dev/null +++ b/views/pages/index.gotmpl @@ -0,0 +1,12 @@ +{{ define "page" }} + +
+ +
+ +
+

SMon

+

{{ .VERSION }}

+
+ +{{ end }} diff --git a/views/pages/problems.gotmpl b/views/pages/problems.gotmpl new file mode 100644 index 0000000..0b8c995 --- /dev/null +++ b/views/pages/problems.gotmpl @@ -0,0 +1,17 @@ +{{ define "page" }} + {{ block "page_label" . }}{{end}} + +
+ {{ range .Data.Areas }} +
+
{{ .Name }}
+ {{ range .SortedSections }} +
+
{{ .Name }}
+
+ {{ end }} +
+ {{ end }} +
+ +{{ end }} diff --git a/views/pages/trigger_edit.gotmpl b/views/pages/trigger_edit.gotmpl new file mode 100644 index 0000000..1624cd3 --- /dev/null +++ b/views/pages/trigger_edit.gotmpl @@ -0,0 +1,40 @@ +{{ define "page" }} + + + + {{ block "page_label" . }}{{end}} + +
+
+
Name
+
+ +
Datapoints
+
+ {{ range .Data.Datapoints }} +
{{ .Name }}
+
{{ .LastDatapointValue.Value }}
+ {{ end }} +
+ +
Expression
+
+ +
+
+ + +
+
+ + +{{ end }} diff --git a/views/pages/triggers.gotmpl b/views/pages/triggers.gotmpl new file mode 100644 index 0000000..8985fad --- /dev/null +++ b/views/pages/triggers.gotmpl @@ -0,0 +1,33 @@ +{{ define "page" }} + + {{ block "page_label" . }}{{end}} + {{ $version := .VERSION }} + +
+ {{ range .Data.Areas }} +
+
{{ .Name }}
+ {{ range .SortedSections }} +
+
{{ .Name }}
+ +
+ {{ range .SortedTriggers }} + {{ if eq .Name "" }} + {{ continue }} + {{ end }} + +
+ +
{{ .Name }}
+
+
+ {{ end }} +
+
+ {{ end }} +
+ {{ end }} +
+ +{{ end }}