diff --git a/datapoint.go b/datapoint.go index 07346e8..f851a5b 100644 --- a/datapoint.go +++ b/datapoint.go @@ -21,14 +21,16 @@ const ( ) type Datapoint struct { - ID int - Group string - Name string - Datatype DatapointType - LastValue time.Time `db:"last_value"` - DatapointValueJSON []byte `db:"datapoint_value_json"` - LastDatapointValue DatapointValue - Found bool + ID int + Group string + Name string + Datatype DatapointType + LastValue time.Time `db:"last_value"` + DatapointValueJSON []byte `db:"datapoint_value_json"` + LastDatapointValue DatapointValue + Found bool + NodataProblemSeconds int `db:"nodata_problem_seconds"` + NodataIsProblem bool `db:"nodata_is_problem"` } type DatapointValue struct { @@ -56,13 +58,13 @@ func (dp DatapointValue) Value() any { // {{{ return nil } // }}} -func (dp DatapointValue) FormattedTime() string {// {{{ +func (dp DatapointValue) FormattedTime() string { // {{{ if dp.ValueDateTime.Valid { return dp.ValueDateTime.Time.Format("2006-01-02 15:04:05") } return "invalid time" -}// }}} -func (dp Datapoint) Update() (err error) {// {{{ +} // }}} +func (dp Datapoint) Update() (err error) { // {{{ name := strings.TrimSpace(dp.Name) if name == "" { err = errors.New("Name can't be empty") @@ -71,23 +73,47 @@ func (dp Datapoint) Update() (err error) {// {{{ if dp.ID == 0 { _, err = service.Db.Conn.Exec( - `INSERT INTO datapoint("group", name, datatype) VALUES($1, $2, $3)`, + `INSERT INTO datapoint("group", name, datatype) VALUES($1, $2, $3, $4)`, dp.Group, name, dp.Datatype, + dp.NodataProblemSeconds, ) } else { + /* Keep nodata_is_problem as is unless the nodata_problem_seconds is changed. + * Otherwise unnecessary nodata problems could be notified when updating unrelated + * datapoint properties. */ _, err = service.Db.Conn.Exec( - `UPDATE datapoint SET "group"=$2, name=$3, datatype=$4 WHERE id=$1`, + ` + UPDATE datapoint + SET + "group"=$2, + name=$3, + datatype=$4, + nodata_problem_seconds=$5, + nodata_is_problem = ( + CASE + WHEN $5 != nodata_problem_seconds THEN false + ELSE + nodata_is_problem + END + ) + WHERE + id=$1 + `, dp.ID, dp.Group, name, dp.Datatype, + dp.NodataProblemSeconds, ) } + if err != nil { + err = werr.Wrap(err) + } return -}// }}} +} // }}} func DatapointAdd[T any](name string, value T) (err error) { // {{{ row := service.Db.Conn.QueryRow(`SELECT id, datatype FROM datapoint WHERE name=$1`, name) @@ -120,7 +146,7 @@ func DatapointAdd[T any](name string, value T) (err error) { // {{{ return } - service.Db.Conn.Exec(`UPDATE datapoint SET last_value = NOW() WHERE name=$1`, name) + service.Db.Conn.Exec(`UPDATE datapoint SET last_value = NOW(), nodata_is_problem = false WHERE id=$1`, dpID) return } // }}} @@ -253,14 +279,14 @@ func DatapointRetrieve(id int, name string) (dp Datapoint, err error) { // {{{ return } // }}} -func DatapointDelete(id int) (err error) {// {{{ +func DatapointDelete(id int) (err error) { // {{{ _, err = service.Db.Conn.Exec(`DELETE FROM datapoint WHERE id=$1`, id) if err != nil { err = werr.Wrap(err).WithData(id) } return -}// }}} -func DatapointValues(id int) (values []DatapointValue, err error) {// {{{ +} // }}} +func DatapointValues(id int) (values []DatapointValue, err error) { // {{{ rows, err := service.Db.Conn.Queryx(`SELECT * FROM datapoint_value WHERE datapoint_id=$1 ORDER BY ts DESC LIMIT 500`, id) if err != nil { err = werr.Wrap(err).WithData(id) @@ -278,4 +304,4 @@ func DatapointValues(id int) (values []DatapointValue, err error) {// {{{ values = append(values, dpv) } return -}// }}} +} // }}} diff --git a/go.mod b/go.mod index d8dc4da..32bf233 100644 --- a/go.mod +++ b/go.mod @@ -6,14 +6,14 @@ require ( git.gibonuddevalla.se/go/webservice v0.2.15 git.gibonuddevalla.se/go/wrappederror v0.3.4 github.com/expr-lang/expr v1.16.5 + github.com/jmoiron/sqlx v1.3.5 + github.com/lib/pq v1.10.9 ) 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 index 26c1b8f..03a3465 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,7 @@ 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/webservice v0.2.15 h1:ECe63fRDSrg3RJcgYV2pG+WsAQLVG8wvfHennz7aHsY= +git.gibonuddevalla.se/go/webservice v0.2.15/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= diff --git a/main.go b/main.go index ab88c7c..e40494a 100644 --- a/main.go +++ b/main.go @@ -140,6 +140,8 @@ func main() { // {{{ service.Register("/configuration", false, false, pageConfiguration) service.Register("/entry/{datapoint}", false, false, entryDatapoint) + go nodataLoop() + err = service.Start() if err != nil { logger.Error("webserver", "error", werr.Wrap(err)) @@ -553,11 +555,15 @@ func pageDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { return } + var nodataSeconds int + nodataSeconds, _ = strconv.Atoi(r.FormValue("nodata_seconds")) + var dp Datapoint dp.ID = id dp.Group = r.FormValue("group") dp.Name = r.FormValue("name") dp.Datatype = DatapointType(r.FormValue("datatype")) + dp.NodataProblemSeconds = nodataSeconds err = dp.Update() if err != nil { httpError(w, werr.Wrap(err).Log()) diff --git a/nodata.go b/nodata.go new file mode 100644 index 0000000..b30a239 --- /dev/null +++ b/nodata.go @@ -0,0 +1,75 @@ +package main + +import ( + // External + werr "git.gibonuddevalla.se/go/wrappederror" + + // Standard + "database/sql" + "time" +) + +// nodataLoop checks if datapoint last_value is larger than the nodata_problem_seconds period and +// marks them as problems. They are then notified. +func nodataLoop() { + var ids []int + var err error + + // TODO - should be configurable + ticker := time.NewTicker(time.Second * 5) + for { + <-ticker.C + ids, err = nodataDatapointIDs() + if err != nil { + err = werr.Wrap(err).Log() + logger.Error("nodata", "error", err) + continue + } + + if len(ids) == 0 { + continue + } + + logger.Info("nodata", "problem_ids", ids) + } +} + +func nodataDatapointIDs() (ids []int, err error) { + ids = []int{} + + var rows *sql.Rows + rows, err = service.Db.Conn.Query(` + UPDATE datapoint + SET + nodata_is_problem = true + FROM ( + SELECT + id + FROM + datapoint + WHERE + NOT nodata_is_problem AND + extract(EPOCH from (NOW() - last_value))::int > nodata_problem_seconds + ) AS subquery + WHERE + datapoint.id = subquery.id + RETURNING + datapoint.id + + `) + if err != nil { + err = werr.Wrap(err) + return + } + defer rows.Close() + + var id int + for rows.Next() { + if err = rows.Scan(&id); err != nil { + err = werr.Wrap(err) + return + } + ids = append(ids, id) + } + return +} diff --git a/sql/00014.sql b/sql/00014.sql new file mode 100644 index 0000000..e31a5d5 --- /dev/null +++ b/sql/00014.sql @@ -0,0 +1,4 @@ +ALTER TABLE datapoint ADD COLUMN nodata_problem_seconds INT4 NOT NULL DEFAULT 0; +ALTER TABLE datapoint ADD COLUMN nodata_is_problem BOOL NOT NULL DEFAULT false; + +CREATE INDEX datapoint_last_value_idx ON public.datapoint ("last_value"); diff --git a/static/css/datapoints.css b/static/css/datapoints.css index 1963b13..8333e8e 100644 --- a/static/css/datapoints.css +++ b/static/css/datapoints.css @@ -19,7 +19,7 @@ body { } body { background: #282828; - font-family: "Roboto", sans-serif; + font-family: sans-serif; font-weight: 300; color: #d5c4a1; font-size: 11pt; @@ -48,25 +48,16 @@ a:hover { b { font-weight: 500; } -.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, select { - font-family: "Roboto Mono", monospace; + font-family: monospace; background: #202020; color: #d5c4a1; padding: 4px 8px; border: none; font-size: 1em; + line-height: 1.5em; } button { background: #202020; @@ -97,6 +88,16 @@ span.seconds { label { user-select: none; } +.description { + border: 1px solid #737373; + color: #3f9da1; + background: #202020; + padding: 4px 8px; + margin-top: 8px; + white-space: nowrap; + width: min-content; + border-radius: 8px; +} #datapoints { display: grid; grid-template-columns: repeat(5, min-content); @@ -137,6 +138,7 @@ label { } .widgets .label { margin-top: 4px; + white-space: nowrap; } .widgets input[type="text"], .widgets textarea { diff --git a/static/css/main.css b/static/css/main.css index e24674f..c2dc070 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -19,7 +19,7 @@ body { } body { background: #282828; - font-family: "Roboto", sans-serif; + font-family: sans-serif; font-weight: 300; color: #d5c4a1; font-size: 11pt; @@ -48,25 +48,16 @@ a:hover { b { font-weight: 500; } -.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, select { - font-family: "Roboto Mono", monospace; + font-family: monospace; background: #202020; color: #d5c4a1; padding: 4px 8px; border: none; font-size: 1em; + line-height: 1.5em; } button { background: #202020; @@ -97,6 +88,16 @@ span.seconds { label { user-select: none; } +.description { + border: 1px solid #737373; + color: #3f9da1; + background: #202020; + padding: 4px 8px; + margin-top: 8px; + white-space: nowrap; + width: min-content; + border-radius: 8px; +} #layout { display: grid; grid-template-areas: "menu content"; diff --git a/static/css/problems.css b/static/css/problems.css index 5b6316c..080965f 100644 --- a/static/css/problems.css +++ b/static/css/problems.css @@ -19,7 +19,7 @@ body { } body { background: #282828; - font-family: "Roboto", sans-serif; + font-family: sans-serif; font-weight: 300; color: #d5c4a1; font-size: 11pt; @@ -48,25 +48,16 @@ a:hover { b { font-weight: 500; } -.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, select { - font-family: "Roboto Mono", monospace; + font-family: monospace; background: #202020; color: #d5c4a1; padding: 4px 8px; border: none; font-size: 1em; + line-height: 1.5em; } button { background: #202020; @@ -97,6 +88,16 @@ span.seconds { label { user-select: none; } +.description { + border: 1px solid #737373; + color: #3f9da1; + background: #202020; + padding: 4px 8px; + margin-top: 8px; + white-space: nowrap; + width: min-content; + border-radius: 8px; +} #problems-list, #acknowledged-list { display: grid; diff --git a/static/css/theme.css b/static/css/theme.css index 68c59d9..104cf3d 100644 --- a/static/css/theme.css +++ b/static/css/theme.css @@ -19,7 +19,7 @@ body { } body { background: #282828; - font-family: "Roboto", sans-serif; + font-family: sans-serif; font-weight: 300; color: #d5c4a1; font-size: 11pt; @@ -48,25 +48,16 @@ a:hover { b { font-weight: 500; } -.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, select { - font-family: "Roboto Mono", monospace; + font-family: monospace; background: #202020; color: #d5c4a1; padding: 4px 8px; border: none; font-size: 1em; + line-height: 1.5em; } button { background: #202020; @@ -97,3 +88,13 @@ span.seconds { label { user-select: none; } +.description { + border: 1px solid #737373; + color: #3f9da1; + background: #202020; + padding: 4px 8px; + margin-top: 8px; + white-space: nowrap; + width: min-content; + border-radius: 8px; +} diff --git a/static/css/trigger_edit.css b/static/css/trigger_edit.css index 477f462..b2600b6 100644 --- a/static/css/trigger_edit.css +++ b/static/css/trigger_edit.css @@ -19,7 +19,7 @@ body { } body { background: #282828; - font-family: "Roboto", sans-serif; + font-family: sans-serif; font-weight: 300; color: #d5c4a1; font-size: 11pt; @@ -48,25 +48,16 @@ a:hover { b { font-weight: 500; } -.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, select { - font-family: "Roboto Mono", monospace; + font-family: monospace; background: #202020; color: #d5c4a1; padding: 4px 8px; border: none; font-size: 1em; + line-height: 1.5em; } button { background: #202020; @@ -97,6 +88,16 @@ span.seconds { label { user-select: none; } +.description { + border: 1px solid #737373; + color: #3f9da1; + background: #202020; + padding: 4px 8px; + margin-top: 8px; + white-space: nowrap; + width: min-content; + border-radius: 8px; +} .widgets { display: grid; grid-template-columns: min-content 1fr; diff --git a/static/js/datapoint_edit.mjs b/static/js/datapoint_edit.mjs index 611011c..dfe5e9c 100644 --- a/static/js/datapoint_edit.mjs +++ b/static/js/datapoint_edit.mjs @@ -1,7 +1,7 @@ export class UI { constructor() { document.addEventListener('keydown', evt=>this.keyHandler(evt)) - document.querySelector('input[name="name"]').focus() + document.querySelector('input[name="group"]').focus() } keyHandler(evt) { if (!(evt.altKey && evt.shiftKey)) diff --git a/static/less/datapoints.less b/static/less/datapoints.less index 26ee63d..770db5f 100644 --- a/static/less/datapoints.less +++ b/static/less/datapoints.less @@ -47,6 +47,7 @@ .label { margin-top: 4px; + white-space: nowrap; } input[type="text"], textarea { diff --git a/static/less/theme.less b/static/less/theme.less index 513db5f..11ae2fc 100644 --- a/static/less/theme.less +++ b/static/less/theme.less @@ -44,7 +44,7 @@ body { body { background: @bg1; - font-family: "Roboto", sans-serif; + font-family: sans-serif; font-weight: 300; color: @text1; font-size: 11pt; @@ -80,27 +80,16 @@ b { font-weight: @bold; } -.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, select { - font-family: "Roboto Mono", monospace; + font-family: monospace; background: @bg2; color: @text1; padding: 4px 8px; border: none; font-size: 1em; + line-height: 1.5em; // fix for chrome hiding underscores } button { @@ -138,3 +127,14 @@ span.seconds { label { user-select: none; } + +.description { + border: 1px solid .lighterOrDarker(@bg3, 25%)[@result]; + color: @color4; + background: @bg2; + padding: 4px 8px; + margin-top: 8px; + white-space: nowrap; + width: min-content; + border-radius: 8px; +} diff --git a/views/pages/datapoint_edit.gotmpl b/views/pages/datapoint_edit.gotmpl index 9e9ee66..9d65b50 100644 --- a/views/pages/datapoint_edit.gotmpl +++ b/views/pages/datapoint_edit.gotmpl @@ -25,6 +25,13 @@ +
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.
+
+
{{ if eq .Data.Datapoint.ID 0 }} diff --git a/views/pages/datapoint_values.gotmpl b/views/pages/datapoint_values.gotmpl index f568b95..fe4651c 100644 --- a/views/pages/datapoint_values.gotmpl +++ b/views/pages/datapoint_values.gotmpl @@ -2,19 +2,8 @@ {{ $version := .VERSION }} - - {{ block "page_label" . }}{{end}} -
- -
-
{{ range .Data.Values }}
{{ format_time .Ts }}