From 89f483171aa9ac205dbd91028add7b21a8943db9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Mon, 29 Apr 2024 08:36:13 +0200 Subject: [PATCH] Initial commit --- .gitignore | 2 + area.go | 62 ++++ config.go | 5 + datapoint.go | 112 +++++++ go.mod | 19 ++ go.sum | 33 ++ main.go | 391 +++++++++++++++++++++++ page.go | 59 ++++ problem.go | 20 ++ section.go | 23 ++ sql/00001.sql | 10 + sql/00002.sql | 10 + sql/00003.sql | 13 + sql/00004.sql | 2 + sql/00005.sql | 11 + static/css/main.css | 143 +++++++++ static/css/theme.css | 64 ++++ static/css/trigger_edit.css | 101 ++++++ static/images/configuration.svg | 69 ++++ static/images/configuration_selected.svg | 67 ++++ static/images/logo.svg | 73 +++++ static/images/logo_selected.svg | 71 ++++ static/images/problems.svg | 69 ++++ static/images/problems_selected.svg | 67 ++++ static/images/triggers.svg | 69 ++++ static/images/triggers_selected.svg | 67 ++++ static/js/trigger_edit.mjs | 53 +++ static/less/Makefile | 11 + static/less/loop_make.sh | 15 + static/less/main.less | 102 ++++++ static/less/theme.less | 82 +++++ static/less/trigger_edit.less | 45 +++ trigger.go | 143 +++++++++ views/components/head_fonts.gotmpl | 6 + views/components/menu.gotmpl | 8 + views/components/page_label.gotmpl | 6 + views/layouts/main.gotmpl | 17 + views/pages/area_new.gotmpl | 3 + views/pages/configuration.gotmpl | 20 ++ views/pages/index.gotmpl | 12 + views/pages/problems.gotmpl | 17 + views/pages/trigger_edit.gotmpl | 40 +++ views/pages/triggers.gotmpl | 33 ++ 43 files changed, 2245 insertions(+) create mode 100644 .gitignore create mode 100644 area.go create mode 100644 config.go create mode 100644 datapoint.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 page.go create mode 100644 problem.go create mode 100644 section.go create mode 100644 sql/00001.sql create mode 100644 sql/00002.sql create mode 100644 sql/00003.sql create mode 100644 sql/00004.sql create mode 100644 sql/00005.sql create mode 100644 static/css/main.css create mode 100644 static/css/theme.css create mode 100644 static/css/trigger_edit.css create mode 100644 static/images/configuration.svg create mode 100644 static/images/configuration_selected.svg create mode 100644 static/images/logo.svg create mode 100644 static/images/logo_selected.svg create mode 100644 static/images/problems.svg create mode 100644 static/images/problems_selected.svg create mode 100644 static/images/triggers.svg create mode 100644 static/images/triggers_selected.svg create mode 100644 static/js/trigger_edit.mjs create mode 100644 static/less/Makefile create mode 100755 static/less/loop_make.sh create mode 100644 static/less/main.less create mode 100644 static/less/theme.less create mode 100644 static/less/trigger_edit.less create mode 100644 trigger.go create mode 100644 views/components/head_fonts.gotmpl create mode 100644 views/components/menu.gotmpl create mode 100644 views/components/page_label.gotmpl create mode 100644 views/layouts/main.gotmpl create mode 100644 views/pages/area_new.gotmpl create mode 100644 views/pages/configuration.gotmpl create mode 100644 views/pages/index.gotmpl create mode 100644 views/pages/problems.gotmpl create mode 100644 views/pages/trigger_edit.gotmpl create mode 100644 views/pages/triggers.gotmpl 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 }}