From ffe0c3def21c4470551becbda4066bb40956c971 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 30 Apr 2024 20:10:05 +0200 Subject: [PATCH 1/4] Wip problem --- datapoint.go | 8 ++++---- main.go | 19 ++++++++---------- trigger.go | 56 +++++++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 61 insertions(+), 22 deletions(-) diff --git a/datapoint.go b/datapoint.go index 3672033..4d36578 100644 --- a/datapoint.go +++ b/datapoint.go @@ -53,13 +53,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") @@ -82,7 +82,7 @@ func (dp Datapoint) Update() (err error) { } 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) diff --git a/main.go b/main.go index 87fab04..3020763 100644 --- a/main.go +++ b/main.go @@ -170,6 +170,13 @@ func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { / return } + var triggers []Trigger + triggers, err = TriggersRetrieveByDatapoint(dpoint) + for _, trigger := range triggers { + trigger.Run() + } + + j, _ := json.Marshal(struct{ OK bool }{true}) w.Write(j) } // }}} @@ -436,23 +443,13 @@ func pageTriggerRun(w http.ResponseWriter, r *http.Request, _ *session.T) { // { expr, _ := io.ReadAll(r.Body) trigger.Expression = string(expr) - datapoints := make(map[string]Datapoint) - for _, dpname := range trigger.Datapoints { - dp, err := DatapointRetrieve(0, dpname) - if err != nil { - httpError(w, we.Wrap(err).Log()) - return - } - datapoints[dpname] = dp - } - resp := struct { OK bool Output any }{ OK: true, } - resp.Output, err = trigger.Run(datapoints) + resp.Output, err = trigger.Run() if err != nil { we.Wrap(err).Log() httpError(w, err) diff --git a/trigger.go b/trigger.go index f472a99..a7944d5 100644 --- a/trigger.go +++ b/trigger.go @@ -4,6 +4,7 @@ import ( // External we "git.gibonuddevalla.se/go/wrappederror" "github.com/expr-lang/expr" + "github.com/jmoiron/sqlx" // Standard "encoding/json" @@ -73,6 +74,37 @@ func TriggersRetrieve() (areas []Area, err error) { // {{{ return } // }}} +func TriggersRetrieveByDatapoint(datapointName string) (triggers []Trigger, err error) { // {{{ + triggers = []Trigger{} + var rows *sqlx.Rows + + rows, err = service.Db.Conn.Queryx(` + SELECT + * + FROM public."trigger" + WHERE + datapoints @> $1 + `, + fmt.Sprintf(`["%s"]`, datapointName), + ) + if err != nil { + err = we.Wrap(err).WithData(datapointName) + return + } + defer rows.Close() + + for rows.Next() { + trigger := Trigger{} + err = rows.StructScan(&trigger) + if err != nil { + err = we.Wrap(err).WithData(datapointName) + return + } + triggers = append(triggers, trigger) + } + + return +} // }}} func TriggerRetrieve(id int) (trigger Trigger, err error) { // {{{ row := service.Db.Conn.QueryRow(`SELECT to_jsonb(t.*) FROM "trigger" t WHERE id=$1`, id) var jsonData []byte @@ -85,7 +117,7 @@ func TriggerRetrieve(id int) (trigger Trigger, err error) { // {{{ err = json.Unmarshal(jsonData, &trigger) return } // }}} -func (t *Trigger) Validate() (ok bool, err error) { +func (t *Trigger) Validate() (ok bool, err error) { // {{{ if strings.TrimSpace(t.Name) == "" { err = fmt.Errorf("Name can't be empty") return @@ -97,8 +129,8 @@ func (t *Trigger) Validate() (ok bool, err error) { } return true, nil -} -func (t *Trigger) Update() (err error) { +} // }}} +func (t *Trigger) Update() (err error) { // {{{ var ok bool if ok, err = t.Validate(); !ok { return @@ -121,9 +153,20 @@ func (t *Trigger) Update() (err error) { err = we.Wrap(err) } return -} +} // }}} + +func (t *Trigger) Run() (output any, err error) { // {{{ + datapoints := make(map[string]Datapoint) + for _, dpname := range t.Datapoints { + var dp Datapoint + dp, err = DatapointRetrieve(0, dpname) + if err != nil { + err = we.Wrap(err) + return + } + datapoints[dpname] = dp + } -func (t *Trigger) Run(datapoints map[string]Datapoint) (output any, err error) { env := make(map[string]any) for dpName, dp := range datapoints { env[dpName] = dp.LastDatapointValue.Value() @@ -138,6 +181,5 @@ func (t *Trigger) Run(datapoints map[string]Datapoint) (output any, err error) { if err != nil { return } - return -} +} // }}} From b0a0f9290e7c845785ac2053a0495abba6ae2af4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Tue, 30 Apr 2024 21:23:05 +0200 Subject: [PATCH 2/4] Trigger expression evaluated and problems managed --- main.go | 36 ++++++++++++++++++++++++++++++++++-- problem.go | 40 ++++++++++++++++++++++++++++++++++++++-- trigger.go | 26 ++++++++++---------------- 3 files changed, 82 insertions(+), 20 deletions(-) diff --git a/main.go b/main.go index 3020763..4d9101a 100644 --- a/main.go +++ b/main.go @@ -172,10 +172,42 @@ func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { / var triggers []Trigger triggers, err = TriggersRetrieveByDatapoint(dpoint) - for _, trigger := range triggers { - trigger.Run() + if err != nil { + logger.Error("entry", "error", err) } + for _, trigger := range triggers { + var out any + out, err = trigger.Run() + if err != nil { + err = we.Wrap(err).Log() + logger.Error("entry", "error", err) + + } + logger.Debug("entry", "datapoint", dpoint, "value", value, "trigger", trigger, "result", out) + switch v := out.(type) { + case bool: + // Trigger returning true - a problem occured + if v { + err = ProblemStart(trigger) + if err != nil { + err = we.Wrap(err).Log() + logger.Error("entry", "error", err) + } + } else { + err = ProblemClose(trigger) + if err != nil { + err = we.Wrap(err).Log() + logger.Error("entry", "error", err) + } + } + + default: + err := fmt.Errorf(`Expression for trigger %s not returning bool (%T)`, trigger.Name, v) + logger.Info("entry", "error", err) + we.Wrap(err).WithData(v).Log() + } + } j, _ := json.Marshal(struct{ OK bool }{true}) w.Write(j) diff --git a/problem.go b/problem.go index 4c7b00e..ab3b149 100644 --- a/problem.go +++ b/problem.go @@ -2,10 +2,10 @@ package main import ( // External - // we "git.gibonuddevalla.se/go/wrappederror" + we "git.gibonuddevalla.se/go/wrappederror" // Standard - // "encoding/json" + "database/sql" ) /* @@ -18,3 +18,39 @@ type Problem struct { } */ +func ProblemStart(trigger Trigger) (err error) { + row := service.Db.Conn.QueryRow(` + SELECT COUNT(id) + FROM problem + WHERE + trigger_id = $1 AND + "end" IS NULL + GROUP BY trigger_id + `, + trigger.ID, + ) + var openProblems int + err = row.Scan(&openProblems) + if err != nil && err != sql.ErrNoRows { + err = we.Wrap(err).WithData(trigger.ID) + return + } + + // Open up a new problem if no open exists. + if openProblems == 0 { + _, err = service.Db.Conn.Exec(`INSERT INTO problem(trigger_id) VALUES($1)`, trigger.ID) + if err != nil { + err = we.Wrap(err).WithData(trigger) + } + } + return +} + +func ProblemClose(trigger Trigger) (err error) { + _, err = service.Db.Conn.Exec(`UPDATE problem SET "end"=NOW() WHERE trigger_id=$1 AND "end" IS NULL`, trigger.ID) + if err != nil { + err = we.Wrap(err).WithData(trigger) + return + } + return +} diff --git a/trigger.go b/trigger.go index a7944d5..eaa797d 100644 --- a/trigger.go +++ b/trigger.go @@ -4,7 +4,6 @@ import ( // External we "git.gibonuddevalla.se/go/wrappederror" "github.com/expr-lang/expr" - "github.com/jmoiron/sqlx" // Standard "encoding/json" @@ -76,31 +75,26 @@ func TriggersRetrieve() (areas []Area, err error) { // {{{ } // }}} func TriggersRetrieveByDatapoint(datapointName string) (triggers []Trigger, err error) { // {{{ triggers = []Trigger{} - var rows *sqlx.Rows - - rows, err = service.Db.Conn.Queryx(` - SELECT - * - FROM public."trigger" + row := service.Db.Conn.QueryRow(` + SELECT jsonb_agg(t.*) + FROM public."trigger" t WHERE datapoints @> $1 `, fmt.Sprintf(`["%s"]`, datapointName), ) + + var data []byte + err = row.Scan(&data) if err != nil { err = we.Wrap(err).WithData(datapointName) return } - defer rows.Close() - for rows.Next() { - trigger := Trigger{} - err = rows.StructScan(&trigger) - if err != nil { - err = we.Wrap(err).WithData(datapointName) - return - } - triggers = append(triggers, trigger) + err = json.Unmarshal(data, &triggers) + if err != nil { + err = we.Wrap(err).WithData(datapointName) + return } return From c746343dc09fe5c37af452df2f0796861fa88644 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Wed, 1 May 2024 10:02:33 +0200 Subject: [PATCH 3/4] Adding datapoints to triggers --- datapoint.go | 1 + main.go | 63 ++++++++++++++---- page.go | 1 - sql/00008.sql | 1 + static/css/datapoints.css | 4 +- static/css/main.css | 25 ++++++-- static/css/theme.css | 4 +- static/css/trigger_edit.css | 4 +- static/js/trigger_edit.mjs | 109 +++++++++++++++++++++++++++----- static/less/main.less | 28 +++++--- static/less/theme.less | 4 +- static/less/trigger_edit.less | 3 + trigger.go | 63 ++++++++++++------ views/pages/trigger_edit.gotmpl | 44 +++++++------ views/pages/triggers.gotmpl | 5 +- 15 files changed, 269 insertions(+), 90 deletions(-) create mode 100644 sql/00008.sql diff --git a/datapoint.go b/datapoint.go index 4d36578..9e925ad 100644 --- a/datapoint.go +++ b/datapoint.go @@ -36,6 +36,7 @@ type DatapointValue struct { ValueInt sql.NullInt64 `db:"value_int"` ValueString sql.NullString `db:"value_string"` ValueDateTime sql.NullTime `db:"value_datetime"` + TemplateValue any } func (dp DatapointValue) Value() any { // {{{ diff --git a/main.go b/main.go index 4d9101a..9e9e62d 100644 --- a/main.go +++ b/main.go @@ -103,13 +103,17 @@ func main() { // {{{ service.Register("/", false, false, staticHandler) service.Register("/problems", false, false, pageProblems) + service.Register("/datapoints", false, false, pageDatapoints) service.Register("/datapoint/edit/{id}", false, false, pageDatapointEdit) service.Register("/datapoint/update/{id}", false, false, pageDatapointUpdate) + service.Register("/triggers", false, false, pageTriggers) service.Register("/trigger/edit/{id}", false, false, pageTriggerEdit) + service.Register("/trigger/edit/{id}/{sectionID}", false, false, pageTriggerEdit) service.Register("/trigger/update/{id}", false, false, pageTriggerUpdate) service.Register("/trigger/run/{id}", false, false, pageTriggerRun) + service.Register("/configuration", false, false, pageConfiguration) service.Register("/entry/{datapoint}", false, false, entryDatapoint) @@ -181,13 +185,13 @@ func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { / if err != nil { err = we.Wrap(err).Log() logger.Error("entry", "error", err) - + } logger.Debug("entry", "datapoint", dpoint, "value", value, "trigger", trigger, "result", out) switch v := out.(type) { case bool: - // Trigger returning true - a problem occured + // Trigger returning true - a problem occurred if v { err = ProblemStart(trigger) if err != nil { @@ -272,6 +276,7 @@ func pageIndex(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ } page.Render(w) } // }}} + func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ page := Page{ LAYOUT: "main", @@ -297,7 +302,8 @@ func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ page.Render(w) return } // }}} -func pageDatapoints(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ + +func pageDatapoints(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ page := Page{ LAYOUT: "main", PAGE: "datapoints", @@ -308,7 +314,13 @@ func pageDatapoints(w http.ResponseWriter, _ *http.Request, _ *session.T) { // { httpError(w, we.Wrap(err).Log()) return } - logger.Info("FOO", "dps", datapoints) + + // The datapoint selector in trigger edit wants the raw data in JSON. + if r.URL.Query().Get("format") == "json" { + j, _ := json.Marshal(datapoints) + w.Write(j) + return + } page.Data = map[string]any{ "Datapoints": datapoints, @@ -371,6 +383,7 @@ func pageDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { w.Header().Add("Location", "/datapoints") w.WriteHeader(302) } // }}} + func pageTriggers(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ areas, err := TriggersRetrieve() if err != nil { @@ -400,11 +413,26 @@ func pageTriggerEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { // return } + // Creating a new trigger uses the edit function. + // ID == 0 - create a new trigger. + // ID > 0 - edit existing trigger. var trigger Trigger - trigger, err = TriggerRetrieve(id) - if err != nil { - httpError(w, we.Wrap(err).Log()) - return + if id > 0 { + trigger, err = TriggerRetrieve(id) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + } else { + // A new trigger needs to know which section it belongs to. + sectionIDStr := r.PathValue("sectionID") + if sectionIDStr != "" { + trigger.SectionID, err = strconv.Atoi(sectionIDStr) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + } } datapoints := make(map[string]Datapoint) @@ -414,6 +442,7 @@ func pageTriggerEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { // httpError(w, we.Wrap(err).Log()) return } + dp.LastDatapointValue.TemplateValue = dp.LastDatapointValue.Value() datapoints[dpname] = dp } @@ -440,14 +469,23 @@ func pageTriggerUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { / } var trigger Trigger - trigger, err = TriggerRetrieve(id) - if err != nil { - httpError(w, we.Wrap(err).Log()) - return + if id > 0 { + trigger, err = TriggerRetrieve(id) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + } else { + trigger.SectionID, err = strconv.Atoi(r.FormValue("sectionID")) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } } trigger.Name = r.FormValue("name") trigger.Expression = r.FormValue("expression") + trigger.Datapoints = r.Form["datapoints[]"] err = trigger.Update() if err != nil { httpError(w, we.Wrap(err).Log()) @@ -492,6 +530,7 @@ func pageTriggerRun(w http.ResponseWriter, r *http.Request, _ *session.T) { // { w.Header().Add("Content-Type", "application/json") w.Write(j) } // }}} + func pageConfiguration(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ areas, err := AreaRetrieve() if err != nil { diff --git a/page.go b/page.go index c6e22d4..2465645 100644 --- a/page.go +++ b/page.go @@ -52,7 +52,6 @@ func (p *Page) Render(w http.ResponseWriter) { "Data": p.Data, } - logger.Info("foo", "tmpl", tmpl) err = tmpl.Execute(w, data) if err != nil { httpError(w, we.Wrap(err).Log()) diff --git a/sql/00008.sql b/sql/00008.sql new file mode 100644 index 0000000..d7498df --- /dev/null +++ b/sql/00008.sql @@ -0,0 +1 @@ +ALTER TABLE public."trigger" ADD CONSTRAINT trigger_sectionname_unique UNIQUE (section_id,"name"); diff --git a/static/css/datapoints.css b/static/css/datapoints.css index dddb99b..ccc0c2a 100644 --- a/static/css/datapoints.css +++ b/static/css/datapoints.css @@ -37,7 +37,7 @@ h2 { font-size: 1.25em; } a { - color: #3f9da1; + color: #fabd2f; text-decoration: none; } a:hover { @@ -70,7 +70,7 @@ button { background: #202020; color: #d5c4a1; padding: 8px 32px; - border: 1px solid #3a3a3a; + border: 1px solid #535353; font-size: 1em; height: 3em; } diff --git a/static/css/main.css b/static/css/main.css index f5b869e..b29b9bb 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -37,7 +37,7 @@ h2 { font-size: 1.25em; } a { - color: #3f9da1; + color: #fabd2f; text-decoration: none; } a:hover { @@ -70,7 +70,7 @@ button { background: #202020; color: #d5c4a1; padding: 8px 32px; - border: 1px solid #3a3a3a; + border: 1px solid #535353; font-size: 1em; height: 3em; } @@ -137,12 +137,17 @@ button:focus { #areas .area .section { margin: 8px 16px; } -#areas .area .section > .name { +#areas .area .section .create { + display: grid; + grid-template-columns: min-content min-content; + grid-gap: 8px; + white-space: nowrap; +} +#areas .area .section .create .new { font-weight: 500; } -#areas .area .section .triggers a { - color: inherit; - text-decoration: none; +#areas .area .section > .name { + font-weight: 500; } #areas .area .section .triggers .trigger { display: grid; @@ -155,5 +160,11 @@ button:focus { height: 16px; } #areas .area .section .triggers .trigger .label { - color: #3f9da1; + color: inherit; +} +dialog { + background: #202020; + border: 1px solid #606060; + color: #d5c4a1; + box-shadow: 10px 10px 15px 0px rgba(0, 0, 0, 0.25); } diff --git a/static/css/theme.css b/static/css/theme.css index 6191e90..1c0dada 100644 --- a/static/css/theme.css +++ b/static/css/theme.css @@ -37,7 +37,7 @@ h2 { font-size: 1.25em; } a { - color: #3f9da1; + color: #fabd2f; text-decoration: none; } a:hover { @@ -70,7 +70,7 @@ button { background: #202020; color: #d5c4a1; padding: 8px 32px; - border: 1px solid #3a3a3a; + border: 1px solid #535353; font-size: 1em; height: 3em; } diff --git a/static/css/trigger_edit.css b/static/css/trigger_edit.css index 41e14b4..e5676af 100644 --- a/static/css/trigger_edit.css +++ b/static/css/trigger_edit.css @@ -37,7 +37,7 @@ h2 { font-size: 1.25em; } a { - color: #3f9da1; + color: #fabd2f; text-decoration: none; } a:hover { @@ -70,7 +70,7 @@ button { background: #202020; color: #d5c4a1; padding: 8px 32px; - border: 1px solid #3a3a3a; + border: 1px solid #535353; font-size: 1em; height: 3em; } diff --git a/static/js/trigger_edit.mjs b/static/js/trigger_edit.mjs index 3d18d82..e534562 100644 --- a/static/js/trigger_edit.mjs +++ b/static/js/trigger_edit.mjs @@ -1,17 +1,35 @@ export class UI { - constructor() { + constructor() {//{{{ document.getElementById('button-run'). - addEventListener('click', evt=>evt.preventDefault()) + addEventListener('click', evt => evt.preventDefault()) - document.addEventListener('keydown', evt=>this.keyHandler(evt)) - } - setTrigger(t) { + document.addEventListener('keydown', evt => this.keyHandler(evt)) + + document.querySelector('input[name="name"]').focus() + + this.datapoints = [] + }//}}} + render() {//{{{ + document.querySelectorAll('.datapoints .datapoint').forEach(el => el.remove()); + + const datapoints = document.querySelector('.datapoints') + + let html = Object.keys(this.trigger.datapoints).sort().map(dpName => { + const dp = this.trigger.datapoints[dpName] + return ` +
${dp.Name}
+
${dp.LastDatapointValue.TemplateValue}
+ ` + }).join('') + datapoints.innerHTML += html + }//}}} + setTrigger(t) {//{{{ this.trigger = t - } - run() { + }//}}} + run() {//{{{ this.trigger.run() - } - keyHandler(evt) { + }//}}} + keyHandler(evt) {//{{{ if (!(evt.altKey && evt.shiftKey)) return @@ -21,21 +39,75 @@ export class UI { switch (evt.key) { case 'T': this.run() - break + break case 'S': - document.getElementById('form-trigger').submit() - break + this.update() + break } - } + }//}}} + addDatapoint() {//{{{ + const dlg = document.getElementById('dlg-datapoints') + const datalist = document.getElementById('list-datapoints') + dlg.showModal() + + fetch('/datapoints?format=json') + .then(data => data.json()) + .then(json => { + this.datapoints = json + + let html = '' + this.datapoints.forEach(dp => { + html += `