diff --git a/README.md b/README.md index 99bc0dd..193705b 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,37 @@ +--- +gitea: none +include_toc: true +--- + +# Smon + +Smon (Simple Monitoring) is a small and easy to maintain single user monitoring system. \ +It is developed for the developer's personal need for monitoring a small number of datapoints and get notifications, +without having to maintain a huge system like Zabbix. \ +It is also possible to troubleshoot with historial data and graphs. + +There is no concept of users or passwords. \ +If a requirement, authentiation and authorization can be handled by a HTTP reverse proxy. + +All data is sent to the system via an HTTP request to /entry/`datapoint_name`. \ +Smon can't poll data. + +## Screenshots + +| Problems | Datapoints | Graphs | Triggers | +| --- | --- | --- | --- | +| | | | | + + + + # Quick start -1) Create an empty database -1) Create the configuration file (default ~/.config/smon.yaml) -1) Run ./smon (will create the database schema) +1) Create an empty database (smon only supports PostgreSQL). +1) Create the configuration file (default ~/.config/smon.yaml). +1) Run ./smon (will create the database schema). -# Configuration +## Configuration ```yaml network: @@ -23,25 +50,117 @@ database: username: smon password: you_wish -session: - daysvalid: 31 - application: logfile: /var/log/smon.log nodata_interval: 60 ``` -# Data from systems to datapoints +## Sending data to datapoints `curl -d 200 http://localhost:9000/entry/datapoint_name` -## Datetime format - -Datetime data is to be in the format of `2006-01-02T15:04:05+02:00` or `2006-01-02T15:04:05+0200`. - Use `date '+%FT%T%z'` to get a value from systems in correct format. -# Theming + + + +# Concepts + +Core concepts in Smon are: +* datapoints. +* triggers. +* problems. +* notifications. +* areas. + +## Datapoints + +A datapoint has a unique name. Recommended is to use only a-z, A-Z, 0-9, dots, dashes and underscores for easier handling in URLs and trigger conditions. + +There are three data types: +* INT. +* STRING. +* DATETIME. + +INT accepts values like -100, 0, 137 and 29561298561 and is stored in the int8 postgresql type. + +STRING is text data and stored by an unlimited varchar. + +DATETIME is a date with time and timezone in the format of `2006-01-02T15:04:05+02:00` or `2006-01-02T15:04:05+0200`. Stored in a field of timestamptz. + +Recommended is to provide from where (machine, system or script location...) the data is being pushed to Smon in the comment field. + +## Triggers + +Triggers have datapoints the expression is being able to use. + +Expressions are written with the [[https://expr-lang.org/|Expr]] language. See the [[https://expr-lang.org/docs/language-definition|language reference]] for available functions and syntax. + +The trigger is evaluating the expression when data is entered through the /entry/`datapoint_name` URL and a problem is issued when the expression returns `true`. + +### Examples + +The disk usage (reported in percent) for server01 has exceeded 90%: + +`server01_disk_usage_root > 90` + +It was more than 48 hours since Borg finished on server01: + +`(now() - borg_server01_finished).Hours() > 48` + +## Problems + +One or more datapoints that trips a trigger issues a problem. + +Problems are archived in the database and can be looked up historically. + +A page of current problems is available to see the current problem state, either as a list or categorised. + +Problems can be acknowledged if they are known problems that will not be currently fixed. + +## Notifications + +Smon has a couple of notification services (currently [[https://ntfy.sh|NTFY]], [[https://pushover.net|Pushover]] and script). + +Services are added with a prio. The service with the lowest prio is tried first. \ +If sending through the service fails, the next service is tried and so on until one succeeds. + +What is sent isn't configurable (for now). What is sent is: +* PROBLEM (when a problem is created) or OK (when a trigger evaluates to false). +* Trigger name + + +## NTFY + +An URL is provided to the topic which should receive the notifications. + +## Pushover + +The user key and API key for the Pushover application is needed. Additionally, a device key can be specified as well. + +## Script + +The script service is defined with a filename. + +The script is called with three parameters: +1) Problem ID +1) Acknowledge URL +1) Message + +## Areas + +Go to the `Config` icon to add areas. These are available to categorize triggers and problems for easier troubleshooting. + +Each area has sections to further group problems and triggers. + + + + +# Development + +A couple of small notes on development. + +## Theming * Add theme to select tag in `/views/pages/configuration.gotmpl`. * Create `/static/less/theme-.less`. diff --git a/datapoint.go b/datapoint.go index a75491a..0690bbd 100644 --- a/datapoint.go +++ b/datapoint.go @@ -54,14 +54,14 @@ func (dp DatapointValue) Value() any { // {{{ } if dp.ValueDateTime.Valid { - return dp.ValueDateTime.Time + return dp.ValueDateTime.Time.In(smonConfig.timezoneLocation) } return nil } // }}} func (dp DatapointValue) FormattedTime() string { // {{{ if dp.ValueDateTime.Valid { - return dp.ValueDateTime.Time.Format("2006-01-02 15:04:05") + return dp.ValueDateTime.Time.In(smonConfig.timezoneLocation).Format("2006-01-02 15:04:05") } return "invalid time" } // }}} @@ -162,8 +162,6 @@ func DatapointAdd[T any](name string, value T) (err error) { // {{{ return } - service.Db.Conn.Exec(`UPDATE datapoint SET last_value = NOW(), nodata_is_problem = false WHERE id=$1`, dpID) - return } // }}} @@ -172,30 +170,19 @@ func DatapointsRetrieve() (dps []Datapoint, err error) { // {{{ var rows *sqlx.Rows rows, err = service.Db.Conn.Queryx(` SELECT - dp.id, - dp.name, - dp.datatype, - dp.last_value, - dp.group, - dp.comment, - dp.nodata_problem_seconds, - - dpv.id AS v_id, - dpv.ts, - dpv.value_int, - dpv.value_string, - dpv.value_datetime - - FROM public.datapoint dp - LEFT JOIN ( - SELECT - *, - row_number() OVER (PARTITION BY "datapoint_id" ORDER BY ts DESC) AS rn - FROM datapoint_value - ) dpv ON dpv.datapoint_id = dp.id AND rn = 1 + id, name, datatype, last_value, "group", comment, nodata_problem_seconds, + last_value_id AS v_id, + CASE + WHEN last_value_id IS NULL THEN null + ELSE last_value + END AS ts, + last_value_int AS value_int, + last_value_string AS value_string, + last_value_datetime AS value_datetime + FROM datapoint ORDER BY - dp.group ASC, - dp.name ASC + "group" ASC, + name ASC `) if err != nil { err = werr.Wrap(err) @@ -255,11 +242,11 @@ func DatapointRetrieve(id int, name string) (dp Datapoint, err error) { // {{{ var query string var param any if id > 0 { - query = `SELECT *, true AS found FROM datapoint WHERE id = $1` + query = `SELECT id, "group", name, "datatype", comment, last_value, nodata_problem_seconds, nodata_is_problem, true AS found FROM public.datapoint WHERE id = $1` param = id dp.ID = id } else { - query = `SELECT *, true AS found FROM datapoint WHERE name = $1` + query = `SELECT id, "group", name, "datatype", comment, last_value, nodata_problem_seconds, nodata_is_problem, true AS found FROM public.datapoint WHERE name = $1` param = name } diff --git a/go.mod b/go.mod index 32bf233..f0f481e 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module smon go 1.22.0 require ( - git.gibonuddevalla.se/go/webservice v0.2.15 + git.gibonuddevalla.se/go/webservice v0.2.16 git.gibonuddevalla.se/go/wrappederror v0.3.4 github.com/expr-lang/expr v1.16.5 github.com/jmoiron/sqlx v1.3.5 diff --git a/helper.go b/helper.go index 99541ba..e8fcbe0 100644 --- a/helper.go +++ b/helper.go @@ -63,7 +63,7 @@ func applyTimeOffset(t time.Time, duration time.Duration, amountStr string) time return t.Add(duration * time.Duration(amount)) } // }}} -func presetTimeInterval(duration time.Duration, presetStr string, timeFrom, timeTo *time.Time) () { +func presetTimeInterval(duration time.Duration, presetStr string, timeFrom, timeTo *time.Time) () {// {{{ if presetStr == "" { return } @@ -73,4 +73,4 @@ func presetTimeInterval(duration time.Duration, presetStr string, timeFrom, time (*timeFrom) = now.Add(duration * -1 * time.Duration(presetTime)) (*timeTo) = now return -} +}// }}} diff --git a/main.go b/main.go index 6755a09..d771363 100644 --- a/main.go +++ b/main.go @@ -29,7 +29,7 @@ import ( "time" ) -const VERSION = "v26" +const VERSION = "v41" var ( logger *slog.Logger @@ -521,7 +521,29 @@ func pageProblems(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ CONFIG: smonConfig.Settings, } - problems, err := ProblemsRetrieve() + // Manage the values from the timefilter component + var err error + var timeFrom, timeTo time.Time + timeFrom, timeTo, err = timefilterParse( + r.URL.Query().Get("time-f"), + r.URL.Query().Get("time-t"), + r.URL.Query().Get("time-offset"), + r.URL.Query().Get("time-preset"), + ) + if err != nil { + httpError(w, err) + return + } + + // GET parameters for this page + var selection string + if r.URL.Query().Get("selection") == "all" { + selection = "ALL" + } else { + selection = "CURRENT" + } + + problems, err := ProblemsRetrieve(selection == "ALL", timeFrom, timeTo) if err != nil { httpError(w, werr.Wrap(err).Log()) return @@ -546,6 +568,12 @@ func pageProblems(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ page.Data = map[string]any{ "Problems": problems, "ProblemsGrouped": problemsGrouped, + "Selection": selection, + + "TimeHidden": selection == "CURRENT", + "TimeSubmit": "/problems", + "TimeFrom": timeFrom.Format("2006-01-02T15:04:05"), + "TimeTo": timeTo.Format("2006-01-02T15:04:05"), } page.Render(w, r) return @@ -635,6 +663,27 @@ func pageDatapointEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { / } } + /* Triggers using this datapoint is provided as a list to update + * if changing the datapoint name. Parsing expr and automatically + * changing it to renamed datapoints would be nice in the future. */ + var triggers []Trigger + triggers, err = TriggersRetrieveByDatapoint(datapoint.Name) + if err != nil { + httpError(w, werr.Wrap(err).Log()) + return + } + slices.SortFunc(triggers, func(a, b Trigger) int { + an := strings.ToUpper(a.Name) + bn := strings.ToUpper(b.Name) + if an < bn { + return -1 + } + if an > bn { + return 1 + } + return 0 + }) + page := Page{ LAYOUT: "main", PAGE: "datapoint_edit", @@ -646,6 +695,7 @@ func pageDatapointEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { / page.Data = map[string]any{ "Datapoint": datapoint, + "Triggers": triggers, } page.Render(w, r) return @@ -661,8 +711,15 @@ func actionDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) var nodataSeconds int nodataSeconds, _ = strconv.Atoi(r.FormValue("nodata_seconds")) + // Datapoint needs to be retrieved from database for the name. + // If name has changed, trigger expressions needs to be updated. var dp Datapoint - dp.ID = id + dp, err = DatapointRetrieve(id, "") + if err != nil { + httpError(w, werr.Wrap(err).WithData(id).Log()) + return + } + prevDatapointName := dp.Name dp.Group = r.FormValue("group") dp.Name = r.FormValue("name") dp.Datatype = DatapointType(r.FormValue("datatype")) @@ -674,6 +731,29 @@ func actionDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) return } + // Update the trigger expressions using this + // datapoint name if changed. + if prevDatapointName != dp.Name { + var triggers []Trigger + triggers, err = TriggersRetrieveByDatapoint(dp.Name) + if err != nil { + httpError(w, werr.Wrap(err).WithData(dp.Name)) + return + } + for _, trigger := range triggers { + err = trigger.RenameDatapoint(prevDatapointName, dp.Name) + if err != nil { + httpError(w, werr.Wrap(err).WithData([]string{prevDatapointName, dp.Name})) + return + } + err = trigger.Update() + if err != nil { + httpError(w, werr.Wrap(err).WithData([]string{prevDatapointName, dp.Name, trigger.Name})) + return + } + } + } + w.Header().Add("Location", "/datapoints") w.WriteHeader(302) } // }}} @@ -709,31 +789,14 @@ func pageDatapointValues(w http.ResponseWriter, r *http.Request, _ *session.T) { return } - // GET parameters. - display := r.URL.Query().Get("display") - if display == "" && datapoint.Datatype == INT { - display = "graph" - } - + // Manage the values from the timefilter component var timeFrom, timeTo time.Time - yesterday := time.Now().Add(time.Duration(-24 * time.Hour)) - timeFrom, err = parseHTMLDateTime(r.URL.Query().Get("f"), yesterday) - if err != nil { - httpError(w, werr.Wrap(err).WithData(r.URL.Query().Get("f")).Log()) - return - } - - timeTo, err = parseHTMLDateTime(r.URL.Query().Get("t"), time.Now()) - if err != nil { - httpError(w, werr.Wrap(err).WithData(r.URL.Query().Get("t")).Log()) - return - } - - presetTimeInterval(time.Hour, r.URL.Query().Get("preset"), &timeFrom, &timeTo) - - // Apply an optionally set offset (in seconds). - timeFrom = applyTimeOffset(timeFrom, time.Second, r.URL.Query().Get("offset-time")) - timeTo = applyTimeOffset(timeTo, time.Second, r.URL.Query().Get("offset-time")) + timeFrom, timeTo, err = timefilterParse( + r.URL.Query().Get("time-f"), + r.URL.Query().Get("time-t"), + r.URL.Query().Get("time-offset"), + r.URL.Query().Get("time-preset"), + ) // Fetch data point values according to the times. var values []DatapointValue @@ -743,6 +806,12 @@ func pageDatapointValues(w http.ResponseWriter, r *http.Request, _ *session.T) { return } + // GET parameters. + display := r.URL.Query().Get("display") + if display == "" && datapoint.Datatype == INT { + display = "graph" + } + page := Page{ LAYOUT: "main", PAGE: "datapoint_values", @@ -755,9 +824,11 @@ func pageDatapointValues(w http.ResponseWriter, r *http.Request, _ *session.T) { page.Data = map[string]any{ "Datapoint": datapoint, "Values": values, - "TimeFrom": timeFrom.Format("2006-01-02T15:04:05"), - "TimeTo": timeTo.Format("2006-01-02T15:04:05"), "Display": display, + + "TimeSubmit": "/datapoint/values/" + strconv.Itoa(datapoint.ID), + "TimeFrom": timeFrom.Format("2006-01-02T15:04:05"), + "TimeTo": timeTo.Format("2006-01-02T15:04:05"), } page.Render(w, r) return @@ -770,14 +841,14 @@ func actionDatapointJson(w http.ResponseWriter, r *http.Request, _ *session.T) { return } - fromStr := r.URL.Query().Get("f") + fromStr := r.URL.Query().Get("time-f") from, err := time.ParseInLocation("2006-01-02 15:04:05", fromStr[0:min(19, len(fromStr))], smonConfig.Timezone()) if err != nil { httpError(w, werr.Wrap(err).Log()) return } - toStr := r.URL.Query().Get("t") + toStr := r.URL.Query().Get("time-t") to, err := time.ParseInLocation("2006-01-02 15:04:05", toStr[0:min(19, len(toStr))], smonConfig.Timezone()) if err != nil { httpError(w, werr.Wrap(err).Log()) @@ -932,7 +1003,22 @@ func actionTriggerDatapointAdd(w http.ResponseWriter, r *http.Request, _ *sessio return } - j, _ := json.Marshal(struct{ OK bool }{OK: true}) + // Also retrieve the datapoint to get the latest value + // for immediate presentation when added. + dp, err := DatapointRetrieve(0, dpName) + if err != nil { + httpError(w, werr.Wrap(err).WithData(dpName).Log()) + return + } + dp.LastDatapointValue.TemplateValue = dp.LastDatapointValue.Value() + j, _ := json.Marshal( + struct { + OK bool + Datapoint Datapoint + }{ + true, + dp, + }) w.Header().Add("Content-Type", "application/json") w.Write(j) } // }}} @@ -1177,6 +1263,8 @@ func actionConfigurationNotificationUpdate(w http.ResponseWriter, r *http.Reques notificationManager.AddService(*svc) } + notificationManager.Reprioritize() + w.Header().Add("Location", "/configuration") w.WriteHeader(302) } // }}} @@ -1198,27 +1286,14 @@ func actionConfigurationNotificationDelete(w http.ResponseWriter, r *http.Reques func pageNotifications(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ var err error - // GET parameters. + // Manage the values from the timefilter component var timeFrom, timeTo time.Time - lastWeek := time.Now().Add(time.Duration(-7 * 24 * time.Hour)) - - timeFrom, err = parseHTMLDateTime(r.URL.Query().Get("f"), lastWeek) - if err != nil { - httpError(w, werr.Wrap(err).Log()) - return - } - - timeTo, err = parseHTMLDateTime(r.URL.Query().Get("t"), time.Now()) - if err != nil { - httpError(w, werr.Wrap(err).Log()) - return - } - - presetTimeInterval(time.Hour, r.URL.Query().Get("preset"), &timeFrom, &timeTo) - - // Apply an optionally set offset (in seconds). - timeFrom = applyTimeOffset(timeFrom, time.Second, r.URL.Query().Get("offset-time")) - timeTo = applyTimeOffset(timeTo, time.Second, r.URL.Query().Get("offset-time")) + timeFrom, timeTo, err = timefilterParse( + r.URL.Query().Get("time-f"), + r.URL.Query().Get("time-t"), + r.URL.Query().Get("time-offset"), + r.URL.Query().Get("time-preset"), + ) nss, err := notificationsSent(timeFrom, timeTo) if err != nil { @@ -1231,6 +1306,7 @@ func pageNotifications(w http.ResponseWriter, r *http.Request, _ *session.T) { / CONFIG: smonConfig.Settings, Data: map[string]any{ "Notifications": nss, + "TimeSubmit": "/notifications", "TimeFrom": timeFrom.Format("2006-01-02T15:04:05"), "TimeTo": timeTo.Format("2006-01-02T15:04:05"), }, diff --git a/notification/factory.go b/notification/factory.go index 16c35d4..1506b43 100644 --- a/notification/factory.go +++ b/notification/factory.go @@ -24,6 +24,16 @@ func ServiceFactory(t string, config []byte, prio int, ackURL string, logger *sl } ntfy.SetLogger(logger) return ntfy, nil + + case "PUSHOVER": + pushover, err := NewPushover(config, prio, ackURL) + if err != nil { + err = werr.Wrap(err).WithData(config) + return nil, err + } + pushover.SetLogger(logger) + return pushover, nil + case "SCRIPT": script, err := NewScript(config, prio, ackURL) if err != nil { @@ -41,6 +51,8 @@ func NewInstance(typ string) Service { switch typ { case "NTFY": return new(NTFY) + case "PUSHOVER": + return new(Pushover) case "SCRIPT": return new(Script) } diff --git a/notification/ntfy.go b/notification/ntfy.go index 0e94516..8132d08 100644 --- a/notification/ntfy.go +++ b/notification/ntfy.go @@ -81,7 +81,7 @@ func (ntfy NTFY) Send(problemID int, msg []byte) (err error) { ackURL := fmt.Sprintf("http, OK, %s/notification/ack?problemID=%d", ntfy.AcknowledgeURL, problemID) req.Header.Add("X-Actions", ackURL) - req.Header.Add("X-Priority", "4") // XXX: should be 5 + req.Header.Add("X-Priority", "5") req.Header.Add("X-Tags", "calendar") res, err = http.DefaultClient.Do(req) diff --git a/notification/pkg.go b/notification/pkg.go index efa6712..948d7b3 100644 --- a/notification/pkg.go +++ b/notification/pkg.go @@ -39,6 +39,9 @@ func NewManager(logger *slog.Logger) (nm Manager) { func (nm *Manager) AddService(service Service) { service.SetExists(true) nm.services = append(nm.services, service) +} + +func (nm *Manager) Reprioritize() { slices.SortFunc(nm.services, func(a, b Service) int { if a.GetPrio() < b.GetPrio() { return -1 diff --git a/notification/pushover.go b/notification/pushover.go new file mode 100644 index 0000000..023ff9d --- /dev/null +++ b/notification/pushover.go @@ -0,0 +1,198 @@ +package notification + +import ( + // External + werr "git.gibonuddevalla.se/go/wrappederror" + + // Standard + "bytes" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strconv" + "strings" +) + +type Pushover struct { + Description string + UserKey string `json:"user_key"` + APIKey string `json:"api_key"` + DeviceName string `json:"device_name"` + + Prio int + AcknowledgeURL string + logger *slog.Logger + + exists bool + updated Service +} + +func init() { + allServices = append(allServices, &Pushover{}) +} + +func NewPushover(config []byte, prio int, ackURL string) (instance *Pushover, err error) { + instance = new(Pushover) + err = json.Unmarshal(config, &instance) + if err != nil { + err = werr.Wrap(err).WithData(config) + return + } + instance.Prio = prio + instance.AcknowledgeURL = ackURL + return instance, nil +} + +func (po *Pushover) SetLogger(l *slog.Logger) { + po.logger = l +} + +func (po *Pushover) GetType() string { + return "PUSHOVER" +} + +func (po *Pushover) GetPrio() int { + return po.Prio +} + +func (po *Pushover) SetPrio(prio int) { + po.Prio = prio +} + +func (po *Pushover) SetExists(exists bool) { + po.exists = exists +} + +func (po Pushover) Exists() bool { + return po.exists +} + +func (po *Pushover) String() string { + if po.Description != "" { + return po.Description + } + + return fmt.Sprintf("%s, %s", po.UserKey, po.APIKey) +} + +func (po Pushover) Send(problemID int, msg []byte) (err error) { + var req *http.Request + var res *http.Response + + pushoverRequest, _ := json.Marshal(map[string]string{ + "token": po.APIKey, + "user": po.UserKey, + "device": po.DeviceName, + "message": string(msg), + }) + + req, err = http.NewRequest("POST", "https://api.pushover.net/1/messages.json", bytes.NewReader(pushoverRequest)) + if err != nil { + err = werr.Wrap(err).WithData(struct { + UserKey string + APIKey string + Msg []byte + }{ + po.UserKey, + po.APIKey, + msg, + }, + ) + return + } + + //ackURL := fmt.Sprintf("http, OK, %s/notification/ack?problemID=%d", po.AcknowledgeURL, problemID) + req.Header.Add("Content-Type", "application/json") + res, err = http.DefaultClient.Do(req) + if err != nil { + err = werr.Wrap(err) + return + } + + body, _ := io.ReadAll(res.Body) + poResp := struct { + Status int + Errors []string + }{} + err = json.Unmarshal(body, &poResp) + if err != nil { + err = werr.Wrap(err).WithData(body) + return + } + + if poResp.Status != 1 { + err = werr.New("%s", strings.Join(poResp.Errors, ", ")) + return + } + + if res.StatusCode != 200 { + err = werr.New("Invalid Pushover response").WithData(body) + return + } + + return +} + +func (po *Pushover) Update(values url.Values) (err error) { + updated := Pushover{} + po.updated = &updated + + // Prio + updated.Prio, err = strconv.Atoi(values.Get("prio")) + if err != nil { + return werr.Wrap(err) + } + + // Description + updated.Description = strings.TrimSpace(values.Get("description")) + + // API (application) key + givenAPIKey := values.Get("api_key") + if strings.TrimSpace(givenAPIKey) == "" { + return werr.New("API key cannot be empty") + } + updated.APIKey = strings.TrimSpace(givenAPIKey) + + // User key + givenUserKey := values.Get("user_key") + if strings.TrimSpace(givenUserKey) == "" { + return werr.New("User key cannot be empty") + } + updated.UserKey = strings.TrimSpace(givenUserKey) + + // Device name + updated.DeviceName = strings.TrimSpace(values.Get("device_name")) + return +} + +func (po *Pushover) Updated() Service { + return po.updated +} + +func (po *Pushover) Commit() { + updatedPushover := po.updated.(*Pushover) + po.Prio = updatedPushover.Prio + po.Description = updatedPushover.Description + po.APIKey = updatedPushover.APIKey + po.UserKey = updatedPushover.UserKey + po.DeviceName = updatedPushover.DeviceName +} + +func (po Pushover) JSON() []byte { + data := struct { + Description string + APIKey string `json:"api_key"` + UserKey string `json:"user_key"` + DeviceName string `json:"device_name"` + }{ + po.Description, + po.APIKey, + po.UserKey, + po.DeviceName, + } + j, _ := json.Marshal(data) + return j +} diff --git a/notification_manager.go b/notification_manager.go index 1690d8e..28afa62 100644 --- a/notification_manager.go +++ b/notification_manager.go @@ -101,7 +101,7 @@ func UpdateNotificationService(svc notification.Service) (created bool, err erro // Check if this is just a duplicated prio, which isn't allowed. pgErr, isPgErr := err.(*pq.Error) if isPgErr && pgErr.Code == "23505" { - return false, werr.New("Prio %d is already used by another service", svc.GetPrio()) + return false, werr.New("Prio %d is already used by another service", svc.Updated().GetPrio()) } return false, werr.Wrap(err).WithData( diff --git a/page.go b/page.go index 38ff22c..a83ed12 100644 --- a/page.go +++ b/page.go @@ -2,7 +2,7 @@ package main import ( // External - we "git.gibonuddevalla.se/go/wrappederror" + werr "git.gibonuddevalla.se/go/wrappederror" // Standard "fmt" @@ -25,7 +25,7 @@ type Page struct { func (p *Page) Render(w http.ResponseWriter, r *http.Request) { tmpl, err := getPage(p.LAYOUT, p.PAGE) if err != nil { - httpError(w, we.Wrap(err).Log()) + httpError(w, werr.Wrap(err).Log()) return } @@ -58,6 +58,6 @@ func (p *Page) Render(w http.ResponseWriter, r *http.Request) { err = tmpl.Execute(w, data) if err != nil { - httpError(w, we.Wrap(err).Log()) + httpError(w, werr.Wrap(err).Log()) } } diff --git a/problem.go b/problem.go index 68a8695..425dc63 100644 --- a/problem.go +++ b/problem.go @@ -2,26 +2,31 @@ package main import ( // External - we "git.gibonuddevalla.se/go/wrappederror" + werr "git.gibonuddevalla.se/go/wrappederror" // Standard "database/sql" "encoding/json" + "fmt" + "sort" + "strings" "time" ) -type Problem struct { - ID int - Start time.Time - End sql.NullTime - Acknowledged bool - TriggerID int `json:"trigger_id"` - TriggerName string `json:"trigger_name"` - AreaName string `json:"area_name"` - SectionName string `json:"section_name"` -} +type Problem struct { // {{{ + ID int + Start time.Time + End time.Time + Acknowledged bool + Datapoints map[string]any + DatapointValues map[string]any `json:"datapoints"` + TriggerID int `json:"trigger_id"` + TriggerName string `json:"trigger_name"` + AreaName string `json:"area_name"` + SectionName string `json:"section_name"` +} // }}} -func ProblemsRetrieve() (problems []Problem, err error) { +func ProblemsRetrieve(showCurrent bool, from, to time.Time) (problems []Problem, err error) { // {{{ problems = []Problem{} row := service.Db.Conn.QueryRow(` SELECT @@ -30,28 +35,39 @@ func ProblemsRetrieve() (problems []Problem, err error) { (SELECT p.id, p.start, - p.end, + TO_CHAR(p.end, 'YYYY-MM-DD"T"HH24:MI:SSTZH:TZM') AS end, p.acknowledged, + p.datapoints, t.id AS trigger_id, - t.name AS trigger_name, + p.trigger_name AS trigger_name, a.name AS area_name, s.name AS section_name FROM problem p - INNER JOIN "trigger" t ON p.trigger_id = t.id - INNER JOIN section s ON t.section_id = s.id - INNER JOIN area a ON s.area_id = a.id + LEFT JOIN "trigger" t ON p.trigger_id = t.id + LEFT JOIN section s ON t.section_id = s.id + LEFT JOIN area a ON s.area_id = a.id WHERE - p.end IS NULL + CASE + WHEN NOT $1 THEN p.end IS NULL + WHEN $1 THEN + p.start >= $2 AND + ( + p.end IS NULL OR + p.end <= $3 + ) + END + ORDER BY p.start DESC) UNION ALL - + (SELECT -1 AS id, - null, + dp.last_value, null, false, + '{}', -1 AS trigger_id, CONCAT( 'NODATA: ', @@ -64,13 +80,16 @@ func ProblemsRetrieve() (problems []Problem, err error) { dp.nodata_is_problem ORDER BY dp.name ASC) - ) AS problems - `) + ) AS problems`, + showCurrent, + from, + to, + ) var jsonBody []byte err = row.Scan(&jsonBody) if err != nil { - err = we.Wrap(err) + err = werr.Wrap(err) return } @@ -80,12 +99,11 @@ func ProblemsRetrieve() (problems []Problem, err error) { err = json.Unmarshal(jsonBody, &problems) if err != nil { - err = we.Wrap(err) + err = werr.Wrap(err) } return -} - -func ProblemStart(trigger Trigger) (problemID int, err error) { +} // }}} +func ProblemStart(trigger Trigger) (problemID int, err error) { // {{{ row := service.Db.Conn.QueryRow(` SELECT COUNT(id) FROM problem @@ -99,22 +117,28 @@ func ProblemStart(trigger Trigger) (problemID int, err error) { var openProblems int err = row.Scan(&openProblems) if err != nil && err != sql.ErrNoRows { - err = we.Wrap(err).WithData(trigger.ID) + err = werr.Wrap(err).WithData(trigger.ID) return } // Open up a new problem if no open exists. if openProblems == 0 { - row = service.Db.Conn.QueryRow(`INSERT INTO problem(trigger_id) VALUES($1) RETURNING id`, trigger.ID) + datapointValuesJson, _ := json.Marshal(trigger.DatapointValues) + row = service.Db.Conn.QueryRow( + `INSERT INTO problem(trigger_id, trigger_name, datapoints, trigger_expression) VALUES($1, $2, $3, $4) RETURNING id`, + trigger.ID, + trigger.Name, + datapointValuesJson, + trigger.Expression, + ) err = row.Scan(&problemID) if err != nil { - err = we.Wrap(err).WithData(trigger) + err = werr.Wrap(err).WithData(trigger) } } return -} - -func ProblemClose(trigger Trigger) (problemID int, err error) { +} // }}} +func ProblemClose(trigger Trigger) (problemID int, err error) { // {{{ row := service.Db.Conn.QueryRow(`UPDATE problem SET "end"=NOW() WHERE trigger_id=$1 AND "end" IS NULL RETURNING id`, trigger.ID) err = row.Scan(&problemID) @@ -124,17 +148,51 @@ func ProblemClose(trigger Trigger) (problemID int, err error) { } if err != nil { - err = we.Wrap(err).WithData(trigger) + err = werr.Wrap(err).WithData(trigger) return } return -} - -func ProblemAcknowledge(id int, state bool) (err error) { +} // }}} +func ProblemAcknowledge(id int, state bool) (err error) { // {{{ _, err = service.Db.Conn.Exec(`UPDATE problem SET "acknowledged"=$2 WHERE id=$1`, id, state) if err != nil { - err = we.Wrap(err).WithData(id) + err = werr.Wrap(err).WithData(id) return } return -} +} // }}} + +func (p Problem) FormattedValues() string { // {{{ + out := []string{} + for key, val := range p.DatapointValues { + var keyval string + + switch val.(type) { + case int: + keyval = fmt.Sprintf("%s: %d", key, val) + + case string: + if str, ok := val.(string); ok { + timeVal, err := time.Parse(time.RFC3339, str) + if err == nil { + formattedTime := timeVal.Format("2006-01-02 15:04:05") + keyval = fmt.Sprintf("%s: %s", key, formattedTime) + } else { + keyval = fmt.Sprintf("%s: %s", key, val) + } + } + + default: + keyval = fmt.Sprintf("%s: %v", key, val) + } + + out = append(out, keyval) + + } + sort.Strings(out) + + return strings.Join(out, "\n") +} // }}} +func (p Problem) IsArchived() bool { // {{{ + return !p.End.IsZero() +} // }}} diff --git a/screenshots/datapoint_values.jpg b/screenshots/datapoint_values.jpg new file mode 100644 index 0000000..7bd493d Binary files /dev/null and b/screenshots/datapoint_values.jpg differ diff --git a/screenshots/datapoint_values_small.jpg b/screenshots/datapoint_values_small.jpg new file mode 100644 index 0000000..436f6a5 Binary files /dev/null and b/screenshots/datapoint_values_small.jpg differ diff --git a/screenshots/datapoints.jpg b/screenshots/datapoints.jpg new file mode 100644 index 0000000..ce1610b Binary files /dev/null and b/screenshots/datapoints.jpg differ diff --git a/screenshots/datapoints_small.jpg b/screenshots/datapoints_small.jpg new file mode 100644 index 0000000..9516a8e Binary files /dev/null and b/screenshots/datapoints_small.jpg differ diff --git a/screenshots/problems.jpg b/screenshots/problems.jpg new file mode 100644 index 0000000..ae067d3 Binary files /dev/null and b/screenshots/problems.jpg differ diff --git a/screenshots/problems_small.jpg b/screenshots/problems_small.jpg new file mode 100644 index 0000000..9f09d1c Binary files /dev/null and b/screenshots/problems_small.jpg differ diff --git a/screenshots/triggers.jpg b/screenshots/triggers.jpg new file mode 100644 index 0000000..ea7c37e Binary files /dev/null and b/screenshots/triggers.jpg differ diff --git a/screenshots/triggers_small.jpg b/screenshots/triggers_small.jpg new file mode 100644 index 0000000..1591aa8 Binary files /dev/null and b/screenshots/triggers_small.jpg differ diff --git a/sql/00022.sql b/sql/00022.sql new file mode 100644 index 0000000..34e7d00 --- /dev/null +++ b/sql/00022.sql @@ -0,0 +1,4 @@ +ALTER TABLE public.problem ALTER COLUMN trigger_id DROP NOT NULL; +ALTER TABLE public.problem ADD COLUMN datapoints JSONB NOT NULL DEFAULT '{}'; +ALTER TABLE public.problem DROP CONSTRAINT problem_trigger_fk; +ALTER TABLE public.problem ADD CONSTRAINT problem_trigger_fk FOREIGN KEY (trigger_id) REFERENCES public."trigger"(id) ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/sql/00023.sql b/sql/00023.sql new file mode 100644 index 0000000..b29ea09 --- /dev/null +++ b/sql/00023.sql @@ -0,0 +1 @@ +ALTER TABLE public.problem ADD COLUMN trigger_expression VARCHAR NOT NULL DEFAULT ''; diff --git a/sql/00024.sql b/sql/00024.sql new file mode 100644 index 0000000..b67c1b3 --- /dev/null +++ b/sql/00024.sql @@ -0,0 +1 @@ +ALTER TABLE public.problem ADD COLUMN trigger_name VARCHAR NOT NULL DEFAULT '[Unknown]'; diff --git a/sql/00025.sql b/sql/00025.sql new file mode 100644 index 0000000..8981022 --- /dev/null +++ b/sql/00025.sql @@ -0,0 +1 @@ +ALTER TYPE notification_type ADD VALUE 'PUSHOVER'; diff --git a/sql/00026.sql b/sql/00026.sql new file mode 100644 index 0000000..364ccaa --- /dev/null +++ b/sql/00026.sql @@ -0,0 +1,62 @@ +/* Adding last values to the datapoint table since they are a regularly used value. */ +ALTER TABLE public.datapoint ADD COLUMN last_value_id int4 NULL; +ALTER TABLE public.datapoint ADD COLUMN last_value_int int8 NULL; +ALTER TABLE public.datapoint ADD COLUMN last_value_string varchar NULL; +ALTER TABLE public.datapoint ADD COLUMN last_value_datetime timestamptz NULL; + + + +/* Once-run query to update it to the latest, to avoid user having to wait for the next entry. */ +UPDATE public.datapoint AS dp +SET + last_value_id = dpv.id, + last_value_int = dpv.value_int, + last_value_string = dpv.value_string, + last_value_datetime = dpv.value_datetime +FROM ( + SELECT + dp.id AS datapoint_id, + dpv.id, + dpv.value_int, + dpv.value_string, + dpv.value_datetime + FROM public.datapoint dp + LEFT JOIN ( + SELECT + *, + row_number() OVER (PARTITION BY "datapoint_id" ORDER BY ts DESC) AS rn + FROM datapoint_value + ) dpv ON dpv.datapoint_id = dp.id AND rn = 1 +) AS dpv +WHERE + dpv.datapoint_id = dp.id; + + + +/* A trigger keeps the value current without bugs introduced in software missing the entry. */ +CREATE OR REPLACE FUNCTION datapoint_entry() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS +$$ +BEGIN + UPDATE public.datapoint + SET + nodata_is_problem = false, + last_value = NEW.ts, + last_value_id = NEW.id, + last_value_int = NEW.value_int, + last_value_string = NEW.value_string, + last_value_datetime = NEW.value_datetime + WHERE + id = NEW.datapoint_id; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER datapoint_entry + AFTER INSERT + ON public.datapoint_value + FOR EACH ROW + EXECUTE PROCEDURE datapoint_entry(); diff --git a/sql/00027.sql b/sql/00027.sql new file mode 100644 index 0000000..83a5625 --- /dev/null +++ b/sql/00027.sql @@ -0,0 +1,22 @@ +/* Updating a datapoint name also updates the jsonb array entry */ +CREATE OR REPLACE FUNCTION update_triggers_datapoint_name() + RETURNS TRIGGER + LANGUAGE PLPGSQL + AS +$$ +BEGIN +UPDATE "trigger" + SET + datapoints = (datapoints - OLD.name) || jsonb_build_array(NEW.name) + WHERE + datapoints ? OLD.name; + + RETURN NEW; +END; +$$; + +CREATE TRIGGER datapoint_renamed + AFTER UPDATE + ON public.datapoint + FOR EACH ROW + EXECUTE PROCEDURE update_triggers_datapoint_name(); diff --git a/static/css/default_light/datapoints.css b/static/css/default_light/datapoints.css index c2e4a29..0263818 100644 --- a/static/css/default_light/datapoints.css +++ b/static/css/default_light/datapoints.css @@ -1,3 +1,6 @@ +#datapoints-filter.invalid-regex { + background-color: #ffd5d5; +} #datapoints { display: grid; grid-template-columns: repeat(6, min-content); @@ -24,6 +27,9 @@ font-size: 0.85em; color: #7bb8eb; } +#datapoints .hidden { + display: none !important; +} #datapoints div { white-space: nowrap; align-self: center; @@ -94,27 +100,5 @@ margin-top: 16px; } .graph #graph-values { - height: calc(100vh - 308px); -} -.time-offset { - display: grid; - grid-template-columns: min-content repeat(3, min-content); - grid-gap: 16px; - margin-top: 16px; - align-items: center; - justify-items: center; -} -.time-offset .header-1 { - font-weight: bold; - justify-self: start; -} -.time-offset .header-2 { - font-weight: bold; - justify-self: start; - grid-column: 2 / -1; -} -.time-offset .preset { - white-space: nowrap; - justify-self: start; - padding-right: 32px; + height: calc(100vh - 200px); } diff --git a/static/css/default_light/default_light.css b/static/css/default_light/default_light.css index 638f1a5..7240e14 100644 --- a/static/css/default_light/default_light.css +++ b/static/css/default_light/default_light.css @@ -49,7 +49,9 @@ dialog, #acknowledged-list, #values, #services, -#notifications { +#notifications, +#group, +.table { background-color: #fff !important; border: 1px solid #ddd; box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25); diff --git a/static/css/default_light/gruvbox.css b/static/css/default_light/gruvbox.css index d0860ec..4420fef 100644 --- a/static/css/default_light/gruvbox.css +++ b/static/css/default_light/gruvbox.css @@ -13,3 +13,11 @@ body { background-color: #a00; text-align: center; } +span.error { + color: #f66; +} +input[type="datetime-local"] { + background-color: #1b4e78; + color: #ccc; + border: 1px solid #535353; +} diff --git a/static/css/default_light/main.css b/static/css/default_light/main.css index 10baf58..0a92050 100644 --- a/static/css/default_light/main.css +++ b/static/css/default_light/main.css @@ -1,3 +1,28 @@ +.table { + display: grid; + grid-gap: 6px 16px; + align-items: center; + margin-top: 32px; + margin-bottom: 32px; + background-color: #2979b8; + padding: 16px 24px; + width: min-content; + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} +.table .row { + grid-column: 1 / -1; +} +.table > div { + white-space: nowrap; + line-height: 24px; +} +.table .header { + font-size: 0.85em; + font-weight: bold; + color: #7bb8eb; + line-height: unset !important; +} html { box-sizing: border-box; } @@ -213,6 +238,12 @@ span.time { span.seconds { display: none; } +span.ok { + color: #0a0; +} +span.error { + color: #a00; +} label { user-select: none; } @@ -226,3 +257,35 @@ label { width: min-content; border-radius: 8px; } +#time-selector { + position: absolute; + top: 16px; + right: 16px; + display: grid; + grid-template-columns: 8px repeat(2, min-content) 8px min-content 8px 1px 8px repeat(3, min-content) 8px repeat(3, min-content) 8px 1px 8px repeat(2, min-content) 8px; + grid-gap: 6px 8px; + align-items: center; + width: min-content; + background-color: #fff; + border: 1px solid #2979b8; + border-radius: 6px; +} +#time-selector.hidden { + display: none; +} +#time-selector .vertical-line { + background-color: #2979b8; +} +#time-selector .header { + padding-top: 12px; + font-weight: bold; + font-size: 0.85em; +} +#time-selector button { + width: 100px; + margin-top: 12px; + justify-self: end; +} +#time-selector div { + white-space: nowrap; +} diff --git a/static/css/default_light/notifications.css b/static/css/default_light/notifications.css index c747e0d..e9f86ba 100644 --- a/static/css/default_light/notifications.css +++ b/static/css/default_light/notifications.css @@ -1,45 +1,8 @@ -#time-select { - display: grid; - grid-template-columns: min-content min-content; - grid-gap: 6px 16px; - width: min-content; - border-radius: 6px; -} -#time-select button { - width: 100px; - margin-top: 12px; - justify-self: end; -} -#time-select #time-offsets { - display: grid; - grid-template-columns: min-content repeat(3, min-content); - grid-gap: 16px; - margin-top: 16px; - align-items: center; - justify-items: center; -} -#time-select #time-offsets .header-1 { - font-weight: bold; - justify-self: start; -} -#time-select #time-offsets .header-2 { - font-weight: bold; - justify-self: start; - grid-column: 2 / -1; -} -#time-select #time-offsets .preset { - white-space: nowrap; - justify-self: start; - padding-right: 32px; -} -input[type="datetime-local"] { - padding: 6px; -} #notifications { display: grid; grid-template-columns: repeat(5, min-content); grid-gap: 4px 16px; - margin-top: 32px; + margin-top: 96px; margin-bottom: 32px; background-color: #2979b8; padding: 16px 24px; @@ -55,9 +18,3 @@ input[type="datetime-local"] { font-weight: 800; color: #7bb8eb; } -#notifications .ok { - color: #0a0; -} -#notifications .error { - color: #a00; -} diff --git a/static/css/default_light/problems.css b/static/css/default_light/problems.css index 3a415de..f89666b 100644 --- a/static/css/default_light/problems.css +++ b/static/css/default_light/problems.css @@ -1,57 +1,45 @@ #problems-list, #acknowledged-list { - display: grid; - grid-template-columns: repeat(6, min-content); - grid-gap: 4px 16px; - margin-top: 32px; - margin-bottom: 32px; - background-color: #2979b8; - padding: 16px 24px; - width: min-content; - border-top-left-radius: 8px; - border-top-right-radius: 8px; -} -#problems-list div, -#acknowledged-list div { - white-space: nowrap; - line-height: 24px; -} -#problems-list .header, -#acknowledged-list .header { - font-weight: 800; - color: #7bb8eb; + grid-template-columns: repeat(7, min-content); } #problems-list .trigger, #acknowledged-list .trigger { color: #1b4e78; - font-weight: 800; } -#problems-list .acknowledge img, -#acknowledged-list .acknowledge img { +#problems-list img.acknowledge, +#acknowledged-list img.acknowledge { height: 16px; } +#problems-list .info, +#acknowledged-list .info { + margin-right: 8px; +} +#problems-list .icons, +#acknowledged-list .icons { + display: grid; + grid-template-columns: min-content min-content; + align-items: center; +} #acknowledged-list.hidden { display: none; } -#areas { +#area-grouped { display: flex; flex-wrap: wrap; gap: 24px; margin-top: 16px; + align-items: flex-start; } -#areas .area .section { - display: grid; - grid-template-columns: repeat(2, min-content); - grid-gap: 8px 12px; +#area-grouped .area { + grid-template-columns: repeat(5, min-content); } -#areas .area .section .name { - color: #000; - grid-column: 1 / -1; - font-weight: bold !important; - line-height: 24px; -} -#areas .area .section div { - white-space: nowrap; +#area-grouped .area .section { + padding: 4px 10px; + border-radius: 5px; + background: #2979b8; + color: #fff; + width: min-content; + margin-bottom: 8px; } .hidden { display: none; diff --git a/static/css/default_light/table.css b/static/css/default_light/table.css new file mode 100644 index 0000000..b238040 --- /dev/null +++ b/static/css/default_light/table.css @@ -0,0 +1,25 @@ +.table { + display: grid; + grid-gap: 6px 16px; + align-items: center; + margin-top: 32px; + margin-bottom: 32px; + background-color: #2979b8; + padding: 16px 24px; + width: min-content; + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} +.table .row { + grid-column: 1 / -1; +} +.table > div { + white-space: nowrap; + line-height: 24px; +} +.table .header { + font-size: 0.85em; + font-weight: bold; + color: #7bb8eb; + line-height: unset !important; +} diff --git a/static/css/default_light/trigger_edit.css b/static/css/default_light/trigger_edit.css index 5192604..959c7cf 100644 --- a/static/css/default_light/trigger_edit.css +++ b/static/css/default_light/trigger_edit.css @@ -13,17 +13,24 @@ .widgets .datapoints { font: "Roboto Mono", monospace; display: grid; - grid-template-columns: min-content min-content 1fr; + grid-template-columns: repeat(4, min-content); gap: 6px 8px; margin-bottom: 8px; white-space: nowrap; } +.widgets .datapoints div { + white-space: nowrap; +} .widgets .datapoints .invalid { color: #c83737; } .widgets .datapoints .delete img { height: 16px; } +.widgets .datapoints .values img { + height: 16px; + width: 16px; +} .widgets .action { display: grid; grid-template-columns: min-content min-content 1fr; diff --git a/static/css/gruvbox/datapoints.css b/static/css/gruvbox/datapoints.css index 3cc0ac3..96b0d62 100644 --- a/static/css/gruvbox/datapoints.css +++ b/static/css/gruvbox/datapoints.css @@ -1,3 +1,6 @@ +#datapoints-filter.invalid-regex { + background-color: #ffd5d5; +} #datapoints { display: grid; grid-template-columns: repeat(6, min-content); @@ -24,6 +27,9 @@ font-size: 0.85em; color: #777; } +#datapoints .hidden { + display: none !important; +} #datapoints div { white-space: nowrap; align-self: center; @@ -94,27 +100,5 @@ margin-top: 16px; } .graph #graph-values { - height: calc(100vh - 308px); -} -.time-offset { - display: grid; - grid-template-columns: min-content repeat(3, min-content); - grid-gap: 16px; - margin-top: 16px; - align-items: center; - justify-items: center; -} -.time-offset .header-1 { - font-weight: bold; - justify-self: start; -} -.time-offset .header-2 { - font-weight: bold; - justify-self: start; - grid-column: 2 / -1; -} -.time-offset .preset { - white-space: nowrap; - justify-self: start; - padding-right: 32px; + height: calc(100vh - 200px); } diff --git a/static/css/gruvbox/default_light.css b/static/css/gruvbox/default_light.css index 95d95f2..c4781c3 100644 --- a/static/css/gruvbox/default_light.css +++ b/static/css/gruvbox/default_light.css @@ -49,7 +49,9 @@ dialog, #acknowledged-list, #values, #services, -#notifications { +#notifications, +#group, +.table { background-color: #fff !important; border: 1px solid #ddd; box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25); diff --git a/static/css/gruvbox/gruvbox.css b/static/css/gruvbox/gruvbox.css index d0860ec..6acdbc0 100644 --- a/static/css/gruvbox/gruvbox.css +++ b/static/css/gruvbox/gruvbox.css @@ -13,3 +13,11 @@ body { background-color: #a00; text-align: center; } +span.error { + color: #f66; +} +input[type="datetime-local"] { + background-color: #202020; + color: #ccc; + border: 1px solid #535353; +} diff --git a/static/css/gruvbox/main.css b/static/css/gruvbox/main.css index 98f785f..9a5edea 100644 --- a/static/css/gruvbox/main.css +++ b/static/css/gruvbox/main.css @@ -1,3 +1,28 @@ +.table { + display: grid; + grid-gap: 6px 16px; + align-items: center; + margin-top: 32px; + margin-bottom: 32px; + background-color: #333; + padding: 16px 24px; + width: min-content; + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} +.table .row { + grid-column: 1 / -1; +} +.table > div { + white-space: nowrap; + line-height: 24px; +} +.table .header { + font-size: 0.85em; + font-weight: bold; + color: #777; + line-height: unset !important; +} html { box-sizing: border-box; } @@ -213,6 +238,12 @@ span.time { span.seconds { display: none; } +span.ok { + color: #0a0; +} +span.error { + color: #a00; +} label { user-select: none; } @@ -226,3 +257,35 @@ label { width: min-content; border-radius: 8px; } +#time-selector { + position: absolute; + top: 16px; + right: 16px; + display: grid; + grid-template-columns: 8px repeat(2, min-content) 8px min-content 8px 1px 8px repeat(3, min-content) 8px repeat(3, min-content) 8px 1px 8px repeat(2, min-content) 8px; + grid-gap: 6px 8px; + align-items: center; + width: min-content; + background-color: #282828; + border: 1px solid #333; + border-radius: 6px; +} +#time-selector.hidden { + display: none; +} +#time-selector .vertical-line { + background-color: #333; +} +#time-selector .header { + padding-top: 12px; + font-weight: bold; + font-size: 0.85em; +} +#time-selector button { + width: 100px; + margin-top: 12px; + justify-self: end; +} +#time-selector div { + white-space: nowrap; +} diff --git a/static/css/gruvbox/notifications.css b/static/css/gruvbox/notifications.css index 60db03b..75d8d89 100644 --- a/static/css/gruvbox/notifications.css +++ b/static/css/gruvbox/notifications.css @@ -1,45 +1,8 @@ -#time-select { - display: grid; - grid-template-columns: min-content min-content; - grid-gap: 6px 16px; - width: min-content; - border-radius: 6px; -} -#time-select button { - width: 100px; - margin-top: 12px; - justify-self: end; -} -#time-select #time-offsets { - display: grid; - grid-template-columns: min-content repeat(3, min-content); - grid-gap: 16px; - margin-top: 16px; - align-items: center; - justify-items: center; -} -#time-select #time-offsets .header-1 { - font-weight: bold; - justify-self: start; -} -#time-select #time-offsets .header-2 { - font-weight: bold; - justify-self: start; - grid-column: 2 / -1; -} -#time-select #time-offsets .preset { - white-space: nowrap; - justify-self: start; - padding-right: 32px; -} -input[type="datetime-local"] { - padding: 6px; -} #notifications { display: grid; grid-template-columns: repeat(5, min-content); grid-gap: 4px 16px; - margin-top: 32px; + margin-top: 96px; margin-bottom: 32px; background-color: #333; padding: 16px 24px; @@ -55,9 +18,3 @@ input[type="datetime-local"] { font-weight: 800; color: #777; } -#notifications .ok { - color: #0a0; -} -#notifications .error { - color: #a00; -} diff --git a/static/css/gruvbox/problems.css b/static/css/gruvbox/problems.css index 6c014f7..2af0baf 100644 --- a/static/css/gruvbox/problems.css +++ b/static/css/gruvbox/problems.css @@ -1,57 +1,45 @@ #problems-list, #acknowledged-list { - display: grid; - grid-template-columns: repeat(6, min-content); - grid-gap: 4px 16px; - margin-top: 32px; - margin-bottom: 32px; - background-color: #333; - padding: 16px 24px; - width: min-content; - border-top-left-radius: 8px; - border-top-right-radius: 8px; -} -#problems-list div, -#acknowledged-list div { - white-space: nowrap; - line-height: 24px; -} -#problems-list .header, -#acknowledged-list .header { - font-weight: 800; - color: #777; + grid-template-columns: repeat(7, min-content); } #problems-list .trigger, #acknowledged-list .trigger { color: #fb4934; - font-weight: 800; } -#problems-list .acknowledge img, -#acknowledged-list .acknowledge img { +#problems-list img.acknowledge, +#acknowledged-list img.acknowledge { height: 16px; } +#problems-list .info, +#acknowledged-list .info { + margin-right: 8px; +} +#problems-list .icons, +#acknowledged-list .icons { + display: grid; + grid-template-columns: min-content min-content; + align-items: center; +} #acknowledged-list.hidden { display: none; } -#areas { +#area-grouped { display: flex; flex-wrap: wrap; gap: 24px; margin-top: 16px; + align-items: flex-start; } -#areas .area .section { - display: grid; - grid-template-columns: repeat(2, min-content); - grid-gap: 8px 12px; +#area-grouped .area { + grid-template-columns: repeat(5, min-content); } -#areas .area .section .name { - color: #f7edd7; - grid-column: 1 / -1; - font-weight: bold !important; - line-height: 24px; -} -#areas .area .section div { - white-space: nowrap; +#area-grouped .area .section { + padding: 4px 10px; + border-radius: 5px; + background: #333; + color: #fff; + width: min-content; + margin-bottom: 8px; } .hidden { display: none; diff --git a/static/css/gruvbox/table.css b/static/css/gruvbox/table.css new file mode 100644 index 0000000..d879e8b --- /dev/null +++ b/static/css/gruvbox/table.css @@ -0,0 +1,25 @@ +.table { + display: grid; + grid-gap: 6px 16px; + align-items: center; + margin-top: 32px; + margin-bottom: 32px; + background-color: #333; + padding: 16px 24px; + width: min-content; + border-top-left-radius: 8px; + border-top-right-radius: 8px; +} +.table .row { + grid-column: 1 / -1; +} +.table > div { + white-space: nowrap; + line-height: 24px; +} +.table .header { + font-size: 0.85em; + font-weight: bold; + color: #777; + line-height: unset !important; +} diff --git a/static/css/gruvbox/trigger_edit.css b/static/css/gruvbox/trigger_edit.css index 711dc17..ee5b1fc 100644 --- a/static/css/gruvbox/trigger_edit.css +++ b/static/css/gruvbox/trigger_edit.css @@ -13,17 +13,24 @@ .widgets .datapoints { font: "Roboto Mono", monospace; display: grid; - grid-template-columns: min-content min-content 1fr; + grid-template-columns: repeat(4, min-content); gap: 6px 8px; margin-bottom: 8px; white-space: nowrap; } +.widgets .datapoints div { + white-space: nowrap; +} .widgets .datapoints .invalid { color: #c83737; } .widgets .datapoints .delete img { height: 16px; } +.widgets .datapoints .values img { + height: 16px; + width: 16px; +} .widgets .action { display: grid; grid-template-columns: min-content min-content 1fr; diff --git a/static/images/default_light/forward.svg b/static/images/default_light/forward.svg new file mode 100644 index 0000000..538498a --- /dev/null +++ b/static/images/default_light/forward.svg @@ -0,0 +1,69 @@ + + + + + + + + + + image/svg+xml + + + + + + alert + arrow-right-bold-circle + + + diff --git a/static/images/default_light/ok.svg b/static/images/default_light/ok.svg new file mode 100644 index 0000000..2f2b62e --- /dev/null +++ b/static/images/default_light/ok.svg @@ -0,0 +1,67 @@ + + + + + + + + + + image/svg+xml + + + + + + check-circle + + + diff --git a/static/images/default_light/warning.svg b/static/images/default_light/warning.svg new file mode 100644 index 0000000..93ac751 --- /dev/null +++ b/static/images/default_light/warning.svg @@ -0,0 +1,68 @@ + + + + + + + + + + image/svg+xml + + + + + + alert + + + diff --git a/static/images/gruvbox/forward.svg b/static/images/gruvbox/forward.svg new file mode 100644 index 0000000..538498a --- /dev/null +++ b/static/images/gruvbox/forward.svg @@ -0,0 +1,69 @@ + + + + + + + + + + image/svg+xml + + + + + + alert + arrow-right-bold-circle + + + diff --git a/static/images/gruvbox/ok.svg b/static/images/gruvbox/ok.svg new file mode 100644 index 0000000..2f2b62e --- /dev/null +++ b/static/images/gruvbox/ok.svg @@ -0,0 +1,67 @@ + + + + + + + + + + image/svg+xml + + + + + + check-circle + + + diff --git a/static/images/gruvbox/warning.svg b/static/images/gruvbox/warning.svg new file mode 100644 index 0000000..93ac751 --- /dev/null +++ b/static/images/gruvbox/warning.svg @@ -0,0 +1,68 @@ + + + + + + + + + + image/svg+xml + + + + + + alert + + + diff --git a/static/js/datapoint_edit.mjs b/static/js/datapoint_edit.mjs index dfe5e9c..e9906e0 100644 --- a/static/js/datapoint_edit.mjs +++ b/static/js/datapoint_edit.mjs @@ -1,5 +1,6 @@ export class UI { - constructor() { + constructor(datapointData) { + this.datapoint = datapointData document.addEventListener('keydown', evt=>this.keyHandler(evt)) document.querySelector('input[name="group"]').focus() } diff --git a/static/js/datapoint_values.js b/static/js/datapoint_values.js index 20d0e54..82b0ff7 100644 --- a/static/js/datapoint_values.js +++ b/static/js/datapoint_values.js @@ -1,13 +1,7 @@ -function preset(hours) { - const inputPreset = document.querySelector('input[name="preset"]') - inputPreset.value = hours - inputPreset.form.submit() -} - -function offsetTime(seconds) { - const inputPreset = document.querySelector('input[name="offset-time"]') - inputPreset.value = seconds - inputPreset.form.submit() +function selectDisplay(display) { + const inputDisplay = document.getElementById('input-display') + inputDisplay.value = display + inputDisplay.form.submit() } class Graph { @@ -61,7 +55,9 @@ class Dataset { constructor(id, initialData) { this.datapointID = id this.values = {} - initialData.forEach(v=>this.values[v.ID] = v) + if (initialData === null) + return + initialData.forEach(v => this.values[v.ID] = v) } xValues() { @@ -76,7 +72,7 @@ class Dataset { return fetch(`/datapoint/json/${this.datapointID}?f=${from}&t=${to}`) .then(data => data.json()) .then(datapointValues => { - datapointValues.forEach(dp=>{ + datapointValues.forEach(dp => { this.values[dp.ID] = dp }) document.getElementById('num-values').innerText = Object.keys(this.values).length diff --git a/static/js/problems.mjs b/static/js/problems.mjs index 1e2361f..fa7c921 100644 --- a/static/js/problems.mjs +++ b/static/js/problems.mjs @@ -6,6 +6,7 @@ export class UI { const list = document.getElementById('acknowledged-list') list.classList.remove('hidden') } + this.acknowledgeDisplay(showAcked == 'true') const display = localStorage.getItem('problems_display') if (display === null) @@ -23,17 +24,32 @@ export class UI { } toggleAcknowledged(evt) { - const list = document.getElementById('acknowledged-list') + this.acknowledgeDisplay(evt.target.checked) + } - if (evt.target.checked) { + acknowledgeDisplay(show) { + const list = document.getElementById('acknowledged-list') + const areaItems = document.querySelectorAll('.acked') + + if (show) { list.classList.remove('hidden') + areaItems.forEach(item=>item.classList.remove('hidden')) localStorage.setItem('show_acknowledged', true) } else { list.classList.add('hidden') + areaItems.forEach(item=>item.classList.add('hidden')) localStorage.setItem('show_acknowledged', false) } } + selectCurrent() { + location.href = '/problems?selection=current' + } + + selectAll() { + location.href = '/problems?selection=all' + } + displayList() { document.querySelector('.display-list').classList.remove('hidden') document.querySelector('.display-areas').classList.add('hidden') diff --git a/static/js/trigger_edit.mjs b/static/js/trigger_edit.mjs index f706136..e74c2e3 100644 --- a/static/js/trigger_edit.mjs +++ b/static/js/trigger_edit.mjs @@ -19,9 +19,10 @@ export class UI { let html = Object.keys(this.trigger.datapoints).sort().map(dpName => { const dp = this.trigger.datapoints[dpName] return ` -
${dp.Name}
${dp.Found ? dp.LastDatapointValue.TemplateValue : ''}
+
+
` }).join('') datapoints.innerHTML += html @@ -84,7 +85,7 @@ export class UI { }) }//}}} deleteDatapoint(name) {//{{{ - if (!confirm(`Delete ${name}?`)) { + if (!confirm(`Remove datapoint ${name} from this trigger?`)) { return } @@ -158,7 +159,7 @@ export class Trigger { alert(json.Error) return } - this.datapoints[dp.Name] = dp + this.datapoints[dp.Name] = json.Datapoint }) }//}}} } diff --git a/static/less/datapoints.less b/static/less/datapoints.less index d1486aa..4399cf2 100644 --- a/static/less/datapoints.less +++ b/static/less/datapoints.less @@ -1,5 +1,9 @@ @import "theme-@{THEME}.less"; +#datapoints-filter.invalid-regex { + background-color: #ffd5d5; +} + #datapoints { display: grid; grid-template-columns: repeat(6, min-content); @@ -30,6 +34,10 @@ color: @text3; } + .hidden { + display: none !important; + } + div { white-space: nowrap; align-self: center; @@ -113,33 +121,6 @@ margin-top: 16px; #graph-values { - height: calc(100vh - 308px); - } -} - -.time-offset { - display: grid; - grid-template-columns: min-content repeat(3, min-content); - grid-gap: 16px; - margin-top: 16px; - - align-items: center; - justify-items: center; - - .header-1 { - font-weight: bold; - justify-self: start; - } - - .header-2 { - font-weight: bold; - justify-self: start; - grid-column: ~"2 / -1"; - } - - .preset { - white-space: nowrap; - justify-self: start; - padding-right: 32px; + height: calc(100vh - 200px); } } diff --git a/static/less/default_light.less b/static/less/default_light.less index ba51838..4b75c80 100644 --- a/static/less/default_light.less +++ b/static/less/default_light.less @@ -62,7 +62,7 @@ dialog { border-radius: 8px; } -dialog, #datapoints, #problems-list, #acknowledged-list, #values, #services, #notifications { +dialog, #datapoints, #problems-list, #acknowledged-list, #values, #services, #notifications, #group, .table { background-color: #fff !important; border: 1px solid #ddd; box-shadow: 5px 5px 8px 0px rgba(0,0,0,0.25); diff --git a/static/less/gruvbox.less b/static/less/gruvbox.less index 04e8977..25e8829 100644 --- a/static/less/gruvbox.less +++ b/static/less/gruvbox.less @@ -20,3 +20,13 @@ body { background-color: #a00; text-align: center; } + +span.error { + color: #f66; +} + +input[type="datetime-local"] { + background-color: @bg2; + color: #ccc; + border: 1px solid #535353; +} diff --git a/static/less/main.less b/static/less/main.less index aa11833..dd38af3 100644 --- a/static/less/main.less +++ b/static/less/main.less @@ -1,4 +1,5 @@ @import "theme-@{THEME}.less"; +@import "table.less"; html { box-sizing: border-box; @@ -266,6 +267,14 @@ span.seconds { display: none; } +span.ok { + color: #0a0; +} + +span.error { + color: #a00; +} + label { user-select: none; } @@ -280,3 +289,44 @@ label { width: min-content; border-radius: 8px; } + +#time-selector { + position: absolute; + + top: 16px; + right: 16px; + + display: grid; + grid-template-columns: 8px repeat(2,min-content) 8px min-content 8px 1px 8px repeat(3,min-content) 8px repeat(3,min-content) 8px 1px 8px repeat(2,min-content) 8px; + grid-gap: 6px 8px; + align-items: center; + + width: min-content; + background-color: @bg1; + border: 1px solid @bg3; + border-radius: 6px; + + &.hidden { + display: none; + } + + .vertical-line { + background-color: @bg3; + } + + .header { + padding-top: 12px; + font-weight: bold; + font-size: 0.85em; + } + + button { + width: 100px; + margin-top: 12px; + justify-self: end; + } + + div { + white-space: nowrap; + } +} diff --git a/static/less/notifications.less b/static/less/notifications.less index 00f7db7..d168327 100644 --- a/static/less/notifications.less +++ b/static/less/notifications.less @@ -1,56 +1,10 @@ @import "theme-@{THEME}.less"; -#time-select { - display: grid; - grid-template-columns: min-content min-content; - grid-gap: 6px 16px; - - width: min-content; - border-radius: 6px; - - button { - width: 100px; - margin-top: 12px; - justify-self: end; - } - - #time-offsets { - display: grid; - grid-template-columns: min-content repeat(3, min-content); - grid-gap: 16px; - margin-top: 16px; - - align-items: center; - justify-items: center; - - .header-1 { - font-weight: bold; - justify-self: start; - } - - .header-2 { - font-weight: bold; - justify-self: start; - grid-column: ~"2 / -1"; - } - - .preset { - white-space: nowrap; - justify-self: start; - padding-right: 32px; - } - } -} - -input[type="datetime-local"] { - padding: 6px; -} - #notifications { display: grid; grid-template-columns: repeat(5, min-content); grid-gap: 4px 16px; - margin-top: 32px; + margin-top: 96px; margin-bottom: 32px; background-color: @bg3; padding: 16px 24px; @@ -67,12 +21,4 @@ input[type="datetime-local"] { font-weight: @bold; color: @text3; } - - .ok { - color: #0a0; - } - - .error { - color: #a00; - } } diff --git a/static/less/problems.less b/static/less/problems.less index 601a9b4..e9e334c 100644 --- a/static/less/problems.less +++ b/static/less/problems.less @@ -1,36 +1,24 @@ @import "theme-@{THEME}.less"; #problems-list, #acknowledged-list { - display: grid; - grid-template-columns: repeat(6, min-content); - grid-gap: 4px 16px; - margin-top: 32px; - margin-bottom: 32px; - background-color: @bg3; - padding: 16px 24px; - width: min-content; - border-top-left-radius: 8px; - border-top-right-radius: 8px; - - div { - white-space: nowrap; - line-height: 24px; - } - - .header { - font-weight: @bold; - color: @text3; - } + grid-template-columns: repeat(7, min-content); .trigger { color: @color1; - font-weight: @bold; } - .acknowledge { - img { - height: 16px; - } + img.acknowledge { + height: 16px; + } + + .info { + margin-right: 8px; + } + + .icons { + display: grid; + grid-template-columns: min-content min-content; + align-items: center; } } @@ -38,28 +26,23 @@ display: none; } -#areas { +#area-grouped { display: flex; flex-wrap: wrap; gap: 24px; margin-top: 16px; + align-items: flex-start; .area { + grid-template-columns: repeat(5, min-content); + .section { - display: grid; - grid-template-columns: repeat(2, min-content); - grid-gap: 8px 12px; - - .name { - color: @text2; - grid-column: ~"1 / -1"; - font-weight: bold !important; - line-height: 24px; - } - - div { - white-space: nowrap; - } + padding: 4px 10px; + border-radius: 5px; + background: @bg3; + color: #fff; + width: min-content; + margin-bottom: 8px; } } } diff --git a/static/less/table.less b/static/less/table.less new file mode 100644 index 0000000..0bc4648 --- /dev/null +++ b/static/less/table.less @@ -0,0 +1,31 @@ +@import "theme-@{THEME}.less"; + +.table { + display: grid; + grid-gap: 6px 16px; + align-items: center; + + .row { + grid-column: ~"1 / -1"; + } + + margin-top: 32px; + margin-bottom: 32px; + background-color: @bg3; + padding: 16px 24px; + width: min-content; + border-top-left-radius: 8px; + border-top-right-radius: 8px; + + & > div { + white-space: nowrap; + line-height: 24px; + } + + .header { + font-size: 0.85em; + font-weight: bold; + color: @text3; + line-height: unset !important; + } +} diff --git a/static/less/trigger_edit.less b/static/less/trigger_edit.less index 4e3f0e2..ee1c616 100644 --- a/static/less/trigger_edit.less +++ b/static/less/trigger_edit.less @@ -19,11 +19,15 @@ .datapoints { font: "Roboto Mono", monospace; display: grid; - grid-template-columns: min-content min-content 1fr; + grid-template-columns: repeat(4, min-content); gap: 6px 8px; margin-bottom: 8px; white-space: nowrap; + div { + white-space: nowrap; + } + .invalid { color: #c83737; } @@ -31,6 +35,11 @@ .delete img { height: 16px; } + + .values img { + height: 16px; + width: 16px; + } } .action { diff --git a/timefilter.go b/timefilter.go new file mode 100644 index 0000000..8d221db --- /dev/null +++ b/timefilter.go @@ -0,0 +1,41 @@ +package main + +import ( + // External + werr "git.gibonuddevalla.se/go/wrappederror" + + // Standard + "time" +) + +func timefilterParse(timeFromStr, timeToStr, offset, preset string) (timeFrom, timeTo time.Time, err error) { + if preset != "" { + presetTimeInterval(time.Hour, preset, &timeFrom, &timeTo) + return + } + + yesterday := time.Now().Add(time.Duration(-24 * time.Hour)) + timeFrom, err = parseHTMLDateTime(timeFromStr, yesterday) + if err != nil { + err = werr.Wrap(err).WithData(timeFromStr) + return + } + + timeTo, err = parseHTMLDateTime(timeToStr, time.Now()) + if err != nil { + err = werr.Wrap(err).WithData(timeToStr) + return + } + + // Protect the user from switching from/to dates, leading to + // zero matching values from the database. + if timeFrom.After(timeTo) { + timeFrom, timeTo = timeTo, timeFrom + } + + // Apply an optionally set offset (in seconds). + timeFrom = applyTimeOffset(timeFrom, time.Second, offset) + timeTo = applyTimeOffset(timeTo, time.Second, offset) + + return +} diff --git a/trigger.go b/trigger.go index 2c16eba..d663564 100644 --- a/trigger.go +++ b/trigger.go @@ -4,6 +4,8 @@ import ( // External werr "git.gibonuddevalla.se/go/wrappederror" "github.com/expr-lang/expr" + "github.com/expr-lang/expr/ast" + "github.com/expr-lang/expr/parser" "github.com/lib/pq" // Standard @@ -14,11 +16,23 @@ import ( ) type Trigger struct { - ID int - Name string - SectionID int `db:"section_id"` - Expression string - Datapoints []string + ID int + Name string + SectionID int `db:"section_id"` + Expression string + Datapoints []string + DatapointValues map[string]any +} + +type ExprRenamePatcher struct { + OldName string + NewName string +} + +func (p ExprRenamePatcher) Visit(node *ast.Node) { + if n, ok := (*node).(*ast.IdentifierNode); ok && n.Value == p.OldName { + ast.Patch(node, &ast.IdentifierNode{Value: p.NewName}) + } } func TriggerCreate(sectionID int, name string) (t Trigger, err error) { // {{{ @@ -126,6 +140,14 @@ func TriggerRetrieve(id int) (trigger Trigger, err error) { // {{{ err = json.Unmarshal(jsonData, &trigger) return } // }}} +func TriggerDelete(id int) (err error) { // {{{ + _, err = service.Db.Conn.Exec(`DELETE FROM public.trigger WHERE id=$1`, id) + if err != nil { + return werr.Wrap(err).WithData(id) + } + return +} // }}} + func (t *Trigger) Validate() (ok bool, err error) { // {{{ if strings.TrimSpace(t.Name) == "" { err = fmt.Errorf("Name can't be empty") @@ -211,14 +233,6 @@ func (t *Trigger) Update() (err error) { // {{{ } return } // }}} -func TriggerDelete(id int) (err error) { // {{{ - _, err = service.Db.Conn.Exec(`DELETE FROM public.trigger WHERE id=$1`, id) - if err != nil { - return werr.Wrap(err).WithData(id) - } - return -} // }}} - func (t *Trigger) Run() (output any, err error) { // {{{ datapoints := make(map[string]Datapoint) for _, dpname := range t.Datapoints { @@ -231,9 +245,9 @@ func (t *Trigger) Run() (output any, err error) { // {{{ datapoints[dpname] = dp } - env := make(map[string]any) + t.DatapointValues = make(map[string]any) for dpName, dp := range datapoints { - env[dpName] = dp.LastDatapointValue.Value() + t.DatapointValues[dpName] = dp.LastDatapointValue.Value() } program, err := expr.Compile(t.Expression) @@ -241,9 +255,20 @@ func (t *Trigger) Run() (output any, err error) { // {{{ return } - output, err = expr.Run(program, env) + output, err = expr.Run(program, t.DatapointValues) if err != nil { return } return } // }}} +func (t *Trigger) RenameDatapoint(from, to string) error { // {{{ + tree, err := parser.Parse(t.Expression) + if err != nil { + return werr.Wrap(err).WithData(t.Expression) + } + + ast.Walk(&tree.Node, ExprRenamePatcher{from, to}) + t.Expression = tree.Node.String() + + return nil +} // }}} diff --git a/views/components/timefilter.gotmpl b/views/components/timefilter.gotmpl new file mode 100644 index 0000000..4c97cb8 --- /dev/null +++ b/views/components/timefilter.gotmpl @@ -0,0 +1,118 @@ +{{ define "timefilter" }} + + + +
+ + + +
+ {{/* ====== Row 1 ====== */}} +
+
Date and time
+ +
+
 
+
+ +
Offsets
+ +
+
+
+ +
Presets
+
+ + + + + {{/* ====== Row 2 ====== */}} +
+
From
+ +
+
+ +
+ {{/* Vertical line */}} +
+ +
+
Hour
+
+ +
+ +
+
Week
+
+ +
+ {{/* Vertical line */}} +
+ +
⚫︎ Last hour
+ +
+ + + + + {{/* ====== Row 3 ====== */}} +
+
To
+ +
+
+ +
+ {{/* Vertical line */}} +
+ +
+
Day
+
+ +
+ +
+
Month
+
+ +
+ {{/* Vertical line */}} +
+ + + + +
+ + + + + {{/* ====== Row 4 ====== */}} +
+
+
+
+
+{{ end }} diff --git a/views/pages/datapoint_edit.gotmpl b/views/pages/datapoint_edit.gotmpl index e4a138b..9bb05f8 100644 --- a/views/pages/datapoint_edit.gotmpl +++ b/views/pages/datapoint_edit.gotmpl @@ -3,48 +3,59 @@ {{ block "page_label" . }}{{end}}
-
-
Group
-
+
+
Group
+
-
Name
-
- -
Datatype
-
- -
+
Name
+
-
No data
problem time
(seconds)
-
- -
A problem is raised and notified if an entry isn't made within this time.
-
Set to 0 to disable.
-
+
Datatype
+
+ +
-
Comment
-
- -
+
No data
problem time
(seconds)
+
+ +
A problem is raised and notified if an entry isn't made within this time.
+
Set to 0 to disable.
+
+ +
Comment
+
+ +
-
-
- {{ if eq .Data.Datapoint.ID 0 }} - - {{ else }} - - {{ end }} +
+
+ {{ if eq .Data.Datapoint.ID 0 }} + + {{ else }} + + {{ end }} +
+ +
+
+ Used in the following triggers: +
    + {{ range .Data.Triggers }} +
  • {{ .Name }}
  • + {{ end }} +
+
diff --git a/views/pages/datapoint_values.gotmpl b/views/pages/datapoint_values.gotmpl index c7d1bc8..c2019f9 100644 --- a/views/pages/datapoint_values.gotmpl +++ b/views/pages/datapoint_values.gotmpl @@ -7,56 +7,24 @@ {{ block "page_label" . }}{{end}} -
- - + {{ block "timefilter" . }}{{ end }} - {{ if eq .Data.Datapoint.Datatype "INT" }} -
- - -
- {{ end }} + {{ if eq .Data.Datapoint.Datatype "INT" }} +
+ +
+ +
+ {{ end }} -
-
Values from
-
Values to
- - - - -
-
Presets
-
Offsets
- - - - -
Hour
- - - - - -
Day
- - - - - -
Week
- - - - - -
Month
- -
- -
- -
+ {{ if $graph }}
@@ -72,7 +40,6 @@ {{ .Data.Datapoint.ID }}, {{ .Data.Values }}, ) - {{ else }}
diff --git a/views/pages/datapoints.gotmpl b/views/pages/datapoints.gotmpl index 96bbff0..c0f60bb 100644 --- a/views/pages/datapoints.gotmpl +++ b/views/pages/datapoints.gotmpl @@ -2,11 +2,96 @@ {{ $version := .VERSION }} {{ $theme := .CONFIG.THEME }} + {{ block "page_label" . }}{{end}} Create +
+ +
+
{{ $prevGroup := "15ecfcc0-b1aa-45cd-af9c-74146a7e7f56-not-very-likely" }} {{ range .Data.Datapoints }} @@ -20,18 +105,18 @@
{{ else }} -
+
{{ end }} - -
{{ .Datatype }}
-
{{ .NodataProblemSeconds }}
-
{{ format_time .LastValue }}
+ +
{{ .Datatype }}
+
{{ .NodataProblemSeconds }}
+
{{ format_time .LastValue }}
{{ if eq .Datatype "DATETIME" }} -
{{ if .LastDatapointValue.ValueDateTime.Valid }}{{ format_time .LastDatapointValue.Value }}{{ end }}
+
{{ if .LastDatapointValue.ValueDateTime.Valid }}{{ format_time .LastDatapointValue.Value }}{{ end }}
{{ else }} -
{{ .LastDatapointValue.Value }}
+
{{ .LastDatapointValue.Value }}
{{ end }} -
+
{{ if eq .Comment "" }}
{{ else }} diff --git a/views/pages/notification/pushover.gotmpl b/views/pages/notification/pushover.gotmpl new file mode 100644 index 0000000..f4dd438 --- /dev/null +++ b/views/pages/notification/pushover.gotmpl @@ -0,0 +1,47 @@ +{{ define "page" }} +

Pushover

+ + +
+ +
+
Prio:
+ + +
Description
+ + +
User key: *
+ + +
API (application) key: *
+ + +
Device name:
+ + + +
+
+{{ end }} diff --git a/views/pages/notifications.gotmpl b/views/pages/notifications.gotmpl index 2bf8e8a..d99776a 100644 --- a/views/pages/notifications.gotmpl +++ b/views/pages/notifications.gotmpl @@ -10,61 +10,9 @@ evt.target.close() } } - - function preset(hours) { - const inputPreset = document.querySelector('input[name="preset"]') - inputPreset.value = hours - inputPreset.form.submit() - } - - function offsetTime(seconds) { - const el = document.querySelector('input[name="offset-time"]') - el.value = seconds - el.form.submit() - } -
- - - -
-
From
-
To
- - - -
-
Presets
-
Offsets
- - - - -
Hour
- - - - - -
Day
- - - - - -
Week
- - - - - -
Month
- -
- -
-
+ {{ block "timefilter" . }}{{ end }}
Sent
diff --git a/views/pages/problems.gotmpl b/views/pages/problems.gotmpl index 4df5aee..50f8757 100644 --- a/views/pages/problems.gotmpl +++ b/views/pages/problems.gotmpl @@ -8,90 +8,156 @@ - {{ block "page_label" . }}{{end}} + {{ block "page_label" . }}{{ end }} -
- - +
+
+ Problem selection
+ +
+ +
+
+ Show
+ +
+ +
+ +
+
+ {{ block "timefilter" . }}{{ end }} +