Compare commits

..

No commits in common. "fe9571c4665231eca8082a72d4f54b6a6b506e50" and "6629b834fd8ce1a17064951690b416de81016fa7" have entirely different histories.

16 changed files with 109 additions and 223 deletions

View File

@ -21,16 +21,14 @@ const (
) )
type Datapoint struct { type Datapoint struct {
ID int ID int
Group string Group string
Name string Name string
Datatype DatapointType Datatype DatapointType
LastValue time.Time `db:"last_value"` LastValue time.Time `db:"last_value"`
DatapointValueJSON []byte `db:"datapoint_value_json"` DatapointValueJSON []byte `db:"datapoint_value_json"`
LastDatapointValue DatapointValue LastDatapointValue DatapointValue
Found bool Found bool
NodataProblemSeconds int `db:"nodata_problem_seconds"`
NodataIsProblem bool `db:"nodata_is_problem"`
} }
type DatapointValue struct { type DatapointValue struct {
@ -58,13 +56,13 @@ func (dp DatapointValue) Value() any { // {{{
return nil return nil
} // }}} } // }}}
func (dp DatapointValue) FormattedTime() string { // {{{ func (dp DatapointValue) FormattedTime() string {// {{{
if dp.ValueDateTime.Valid { if dp.ValueDateTime.Valid {
return dp.ValueDateTime.Time.Format("2006-01-02 15:04:05") return dp.ValueDateTime.Time.Format("2006-01-02 15:04:05")
} }
return "invalid time" return "invalid time"
} // }}} }// }}}
func (dp Datapoint) Update() (err error) { // {{{ func (dp Datapoint) Update() (err error) {// {{{
name := strings.TrimSpace(dp.Name) name := strings.TrimSpace(dp.Name)
if name == "" { if name == "" {
err = errors.New("Name can't be empty") err = errors.New("Name can't be empty")
@ -73,47 +71,23 @@ func (dp Datapoint) Update() (err error) { // {{{
if dp.ID == 0 { if dp.ID == 0 {
_, err = service.Db.Conn.Exec( _, err = service.Db.Conn.Exec(
`INSERT INTO datapoint("group", name, datatype) VALUES($1, $2, $3, $4)`, `INSERT INTO datapoint("group", name, datatype) VALUES($1, $2, $3)`,
dp.Group, dp.Group,
name, name,
dp.Datatype, dp.Datatype,
dp.NodataProblemSeconds,
) )
} else { } 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( _, 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.ID,
dp.Group, dp.Group,
name, name,
dp.Datatype, dp.Datatype,
dp.NodataProblemSeconds,
) )
} }
if err != nil {
err = werr.Wrap(err)
}
return return
} // }}} }// }}}
func DatapointAdd[T any](name string, value T) (err error) { // {{{ func DatapointAdd[T any](name string, value T) (err error) { // {{{
row := service.Db.Conn.QueryRow(`SELECT id, datatype FROM datapoint WHERE name=$1`, name) row := service.Db.Conn.QueryRow(`SELECT id, datatype FROM datapoint WHERE name=$1`, name)
@ -146,7 +120,7 @@ func DatapointAdd[T any](name string, value T) (err error) { // {{{
return return
} }
service.Db.Conn.Exec(`UPDATE datapoint SET last_value = NOW(), nodata_is_problem = false WHERE id=$1`, dpID) service.Db.Conn.Exec(`UPDATE datapoint SET last_value = NOW() WHERE name=$1`, name)
return return
} // }}} } // }}}
@ -279,14 +253,14 @@ func DatapointRetrieve(id int, name string) (dp Datapoint, err error) { // {{{
return 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) _, err = service.Db.Conn.Exec(`DELETE FROM datapoint WHERE id=$1`, id)
if err != nil { if err != nil {
err = werr.Wrap(err).WithData(id) err = werr.Wrap(err).WithData(id)
} }
return 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) rows, err := service.Db.Conn.Queryx(`SELECT * FROM datapoint_value WHERE datapoint_id=$1 ORDER BY ts DESC LIMIT 500`, id)
if err != nil { if err != nil {
err = werr.Wrap(err).WithData(id) err = werr.Wrap(err).WithData(id)
@ -304,4 +278,4 @@ func DatapointValues(id int) (values []DatapointValue, err error) { // {{{
values = append(values, dpv) values = append(values, dpv)
} }
return return
} // }}} }// }}}

4
go.mod
View File

@ -6,14 +6,14 @@ require (
git.gibonuddevalla.se/go/webservice v0.2.15 git.gibonuddevalla.se/go/webservice v0.2.15
git.gibonuddevalla.se/go/wrappederror v0.3.4 git.gibonuddevalla.se/go/wrappederror v0.3.4
github.com/expr-lang/expr v1.16.5 github.com/expr-lang/expr v1.16.5
github.com/jmoiron/sqlx v1.3.5
github.com/lib/pq v1.10.9
) )
require ( require (
git.gibonuddevalla.se/go/dbschema v1.3.0 // indirect git.gibonuddevalla.se/go/dbschema v1.3.0 // indirect
github.com/google/uuid v1.5.0 // indirect github.com/google/uuid v1.5.0 // indirect
github.com/gorilla/websocket v1.5.1 // 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 golang.org/x/net v0.17.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

4
go.sum
View File

@ -1,7 +1,7 @@
git.gibonuddevalla.se/go/dbschema v1.3.0 h1:HzFMR29tWfy/ibIjltTbIMI4inVktj/rh8bESALibgM= 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/dbschema v1.3.0/go.mod h1:BNw3q/574nXbGoeWyK+tLhRfggVkw2j2aXZzrBKC3ig=
git.gibonuddevalla.se/go/webservice v0.2.15 h1:ECe63fRDSrg3RJcgYV2pG+WsAQLVG8wvfHennz7aHsY= git.gibonuddevalla.se/go/webservice v0.2.12 h1:IcaIycmF7eO88RmFQkslHaKRWYxXdciVQXUAvJ36b4g=
git.gibonuddevalla.se/go/webservice v0.2.15/go.mod h1:3uBS6nLbK9qbuGzDls8MZD5Xr9ORY1Srbj6v06BIhws= 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 h1:dcKp9/+QrZSO3S4fVnq7yG2p7DUZVmlztBAb/OzoZNY=
git.gibonuddevalla.se/go/wrappederror v0.3.4/go.mod h1:j4w320Hk1wvhOPjUaK4GgLvmtnjUUM5yVu6JFO1OCSc= 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 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=

View File

@ -140,8 +140,6 @@ func main() { // {{{
service.Register("/configuration", false, false, pageConfiguration) service.Register("/configuration", false, false, pageConfiguration)
service.Register("/entry/{datapoint}", false, false, entryDatapoint) service.Register("/entry/{datapoint}", false, false, entryDatapoint)
go nodataLoop()
err = service.Start() err = service.Start()
if err != nil { if err != nil {
logger.Error("webserver", "error", werr.Wrap(err)) logger.Error("webserver", "error", werr.Wrap(err))
@ -555,15 +553,11 @@ func pageDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) {
return return
} }
var nodataSeconds int
nodataSeconds, _ = strconv.Atoi(r.FormValue("nodata_seconds"))
var dp Datapoint var dp Datapoint
dp.ID = id dp.ID = id
dp.Group = r.FormValue("group") dp.Group = r.FormValue("group")
dp.Name = r.FormValue("name") dp.Name = r.FormValue("name")
dp.Datatype = DatapointType(r.FormValue("datatype")) dp.Datatype = DatapointType(r.FormValue("datatype"))
dp.NodataProblemSeconds = nodataSeconds
err = dp.Update() err = dp.Update()
if err != nil { if err != nil {
httpError(w, werr.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())

View File

@ -1,75 +0,0 @@
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
}

View File

@ -1,4 +0,0 @@
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");

View File

@ -19,7 +19,7 @@ body {
} }
body { body {
background: #282828; background: #282828;
font-family: sans-serif; font-family: "Roboto", sans-serif;
font-weight: 300; font-weight: 300;
color: #d5c4a1; color: #d5c4a1;
font-size: 11pt; font-size: 11pt;
@ -48,16 +48,25 @@ a:hover {
b { b {
font-weight: 500; 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"], input[type="text"],
textarea, textarea,
select { select {
font-family: monospace; font-family: "Roboto Mono", monospace;
background: #202020; background: #202020;
color: #d5c4a1; color: #d5c4a1;
padding: 4px 8px; padding: 4px 8px;
border: none; border: none;
font-size: 1em; font-size: 1em;
line-height: 1.5em;
} }
button { button {
background: #202020; background: #202020;
@ -88,16 +97,6 @@ span.seconds {
label { label {
user-select: none; 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 { #datapoints {
display: grid; display: grid;
grid-template-columns: repeat(5, min-content); grid-template-columns: repeat(5, min-content);
@ -138,7 +137,6 @@ label {
} }
.widgets .label { .widgets .label {
margin-top: 4px; margin-top: 4px;
white-space: nowrap;
} }
.widgets input[type="text"], .widgets input[type="text"],
.widgets textarea { .widgets textarea {

View File

@ -19,7 +19,7 @@ body {
} }
body { body {
background: #282828; background: #282828;
font-family: sans-serif; font-family: "Roboto", sans-serif;
font-weight: 300; font-weight: 300;
color: #d5c4a1; color: #d5c4a1;
font-size: 11pt; font-size: 11pt;
@ -48,16 +48,25 @@ a:hover {
b { b {
font-weight: 500; 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"], input[type="text"],
textarea, textarea,
select { select {
font-family: monospace; font-family: "Roboto Mono", monospace;
background: #202020; background: #202020;
color: #d5c4a1; color: #d5c4a1;
padding: 4px 8px; padding: 4px 8px;
border: none; border: none;
font-size: 1em; font-size: 1em;
line-height: 1.5em;
} }
button { button {
background: #202020; background: #202020;
@ -88,16 +97,6 @@ span.seconds {
label { label {
user-select: none; 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 { #layout {
display: grid; display: grid;
grid-template-areas: "menu content"; grid-template-areas: "menu content";

View File

@ -19,7 +19,7 @@ body {
} }
body { body {
background: #282828; background: #282828;
font-family: sans-serif; font-family: "Roboto", sans-serif;
font-weight: 300; font-weight: 300;
color: #d5c4a1; color: #d5c4a1;
font-size: 11pt; font-size: 11pt;
@ -48,16 +48,25 @@ a:hover {
b { b {
font-weight: 500; 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"], input[type="text"],
textarea, textarea,
select { select {
font-family: monospace; font-family: "Roboto Mono", monospace;
background: #202020; background: #202020;
color: #d5c4a1; color: #d5c4a1;
padding: 4px 8px; padding: 4px 8px;
border: none; border: none;
font-size: 1em; font-size: 1em;
line-height: 1.5em;
} }
button { button {
background: #202020; background: #202020;
@ -88,16 +97,6 @@ span.seconds {
label { label {
user-select: none; 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, #problems-list,
#acknowledged-list { #acknowledged-list {
display: grid; display: grid;

View File

@ -19,7 +19,7 @@ body {
} }
body { body {
background: #282828; background: #282828;
font-family: sans-serif; font-family: "Roboto", sans-serif;
font-weight: 300; font-weight: 300;
color: #d5c4a1; color: #d5c4a1;
font-size: 11pt; font-size: 11pt;
@ -48,16 +48,25 @@ a:hover {
b { b {
font-weight: 500; 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"], input[type="text"],
textarea, textarea,
select { select {
font-family: monospace; font-family: "Roboto Mono", monospace;
background: #202020; background: #202020;
color: #d5c4a1; color: #d5c4a1;
padding: 4px 8px; padding: 4px 8px;
border: none; border: none;
font-size: 1em; font-size: 1em;
line-height: 1.5em;
} }
button { button {
background: #202020; background: #202020;
@ -88,13 +97,3 @@ span.seconds {
label { label {
user-select: none; 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;
}

View File

@ -19,7 +19,7 @@ body {
} }
body { body {
background: #282828; background: #282828;
font-family: sans-serif; font-family: "Roboto", sans-serif;
font-weight: 300; font-weight: 300;
color: #d5c4a1; color: #d5c4a1;
font-size: 11pt; font-size: 11pt;
@ -48,16 +48,25 @@ a:hover {
b { b {
font-weight: 500; 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"], input[type="text"],
textarea, textarea,
select { select {
font-family: monospace; font-family: "Roboto Mono", monospace;
background: #202020; background: #202020;
color: #d5c4a1; color: #d5c4a1;
padding: 4px 8px; padding: 4px 8px;
border: none; border: none;
font-size: 1em; font-size: 1em;
line-height: 1.5em;
} }
button { button {
background: #202020; background: #202020;
@ -88,16 +97,6 @@ span.seconds {
label { label {
user-select: none; 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 { .widgets {
display: grid; display: grid;
grid-template-columns: min-content 1fr; grid-template-columns: min-content 1fr;

View File

@ -1,7 +1,7 @@
export class UI { export class UI {
constructor() { constructor() {
document.addEventListener('keydown', evt=>this.keyHandler(evt)) document.addEventListener('keydown', evt=>this.keyHandler(evt))
document.querySelector('input[name="group"]').focus() document.querySelector('input[name="name"]').focus()
} }
keyHandler(evt) { keyHandler(evt) {
if (!(evt.altKey && evt.shiftKey)) if (!(evt.altKey && evt.shiftKey))

View File

@ -47,7 +47,6 @@
.label { .label {
margin-top: 4px; margin-top: 4px;
white-space: nowrap;
} }
input[type="text"], textarea { input[type="text"], textarea {

View File

@ -44,7 +44,7 @@ body {
body { body {
background: @bg1; background: @bg1;
font-family: sans-serif; font-family: "Roboto", sans-serif;
font-weight: 300; font-weight: 300;
color: @text1; color: @text1;
font-size: 11pt; font-size: 11pt;
@ -80,16 +80,27 @@ b {
font-weight: @bold; 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"], input[type="text"],
textarea, textarea,
select { select {
font-family: monospace; font-family: "Roboto Mono", monospace;
background: @bg2; background: @bg2;
color: @text1; color: @text1;
padding: 4px 8px; padding: 4px 8px;
border: none; border: none;
font-size: 1em; font-size: 1em;
line-height: 1.5em; // fix for chrome hiding underscores
} }
button { button {
@ -127,14 +138,3 @@ span.seconds {
label { label {
user-select: none; 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;
}

View File

@ -25,13 +25,6 @@
</select> </select>
</div> </div>
<div class="label">No data<br>problem time<br>(seconds)</div>
<div>
<input type="text" name="nodata_seconds" value="{{ .Data.Datapoint.NodataProblemSeconds }}">
<div class="description">A problem is raised and notified if an entry isn't made within this time.</div>
<div class="description">Set to 0 to disable.</div>
</div>
<div></div> <div></div>
<div class="action"> <div class="action">
{{ if eq .Data.Datapoint.ID 0 }} {{ if eq .Data.Datapoint.ID 0 }}

View File

@ -2,8 +2,19 @@
{{ $version := .VERSION }} {{ $version := .VERSION }}
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/datapoints.css"> <link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/datapoints.css">
<script type="module" defer>
import { DataGraph } from "/js/{{ .VERSION }}/datapoint_graph.mjs"
const graph = new DataGraph({{ .Data.Datapoint }})
graph.setData({{ .Data.Values }})
graph.render('chart')
</script>
{{ block "page_label" . }}{{end}} {{ block "page_label" . }}{{end}}
<div>
<canvas id="chart"></canvas>
</div>
<div id="values"> <div id="values">
{{ range .Data.Values }} {{ range .Data.Values }}
<div class="value">{{ format_time .Ts }}</div> <div class="value">{{ format_time .Ts }}</div>