Adding datapoints to triggers

This commit is contained in:
Magnus Åhall 2024-05-01 10:02:33 +02:00
parent b0a0f9290e
commit c746343dc0
15 changed files with 269 additions and 90 deletions

View File

@ -36,6 +36,7 @@ type DatapointValue struct {
ValueInt sql.NullInt64 `db:"value_int"` ValueInt sql.NullInt64 `db:"value_int"`
ValueString sql.NullString `db:"value_string"` ValueString sql.NullString `db:"value_string"`
ValueDateTime sql.NullTime `db:"value_datetime"` ValueDateTime sql.NullTime `db:"value_datetime"`
TemplateValue any
} }
func (dp DatapointValue) Value() any { // {{{ func (dp DatapointValue) Value() any { // {{{

63
main.go
View File

@ -103,13 +103,17 @@ func main() { // {{{
service.Register("/", false, false, staticHandler) service.Register("/", false, false, staticHandler)
service.Register("/problems", false, false, pageProblems) service.Register("/problems", false, false, pageProblems)
service.Register("/datapoints", false, false, pageDatapoints) service.Register("/datapoints", false, false, pageDatapoints)
service.Register("/datapoint/edit/{id}", false, false, pageDatapointEdit) service.Register("/datapoint/edit/{id}", false, false, pageDatapointEdit)
service.Register("/datapoint/update/{id}", false, false, pageDatapointUpdate) service.Register("/datapoint/update/{id}", false, false, pageDatapointUpdate)
service.Register("/triggers", false, false, pageTriggers) service.Register("/triggers", false, false, pageTriggers)
service.Register("/trigger/edit/{id}", false, false, pageTriggerEdit) 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/update/{id}", false, false, pageTriggerUpdate)
service.Register("/trigger/run/{id}", false, false, pageTriggerRun) service.Register("/trigger/run/{id}", false, false, pageTriggerRun)
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)
@ -181,13 +185,13 @@ func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { /
if err != nil { if err != nil {
err = we.Wrap(err).Log() err = we.Wrap(err).Log()
logger.Error("entry", "error", err) logger.Error("entry", "error", err)
} }
logger.Debug("entry", "datapoint", dpoint, "value", value, "trigger", trigger, "result", out) logger.Debug("entry", "datapoint", dpoint, "value", value, "trigger", trigger, "result", out)
switch v := out.(type) { switch v := out.(type) {
case bool: case bool:
// Trigger returning true - a problem occured // Trigger returning true - a problem occurred
if v { if v {
err = ProblemStart(trigger) err = ProblemStart(trigger)
if err != nil { if err != nil {
@ -272,6 +276,7 @@ func pageIndex(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
} }
page.Render(w) page.Render(w)
} // }}} } // }}}
func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
page := Page{ page := Page{
LAYOUT: "main", LAYOUT: "main",
@ -297,7 +302,8 @@ func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
page.Render(w) page.Render(w)
return return
} // }}} } // }}}
func pageDatapoints(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
func pageDatapoints(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
page := Page{ page := Page{
LAYOUT: "main", LAYOUT: "main",
PAGE: "datapoints", PAGE: "datapoints",
@ -308,7 +314,13 @@ func pageDatapoints(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {
httpError(w, we.Wrap(err).Log()) httpError(w, we.Wrap(err).Log())
return 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{ page.Data = map[string]any{
"Datapoints": datapoints, "Datapoints": datapoints,
@ -371,6 +383,7 @@ func pageDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) {
w.Header().Add("Location", "/datapoints") w.Header().Add("Location", "/datapoints")
w.WriteHeader(302) w.WriteHeader(302)
} // }}} } // }}}
func pageTriggers(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ func pageTriggers(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
areas, err := TriggersRetrieve() areas, err := TriggersRetrieve()
if err != nil { if err != nil {
@ -400,11 +413,26 @@ func pageTriggerEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { //
return return
} }
// Creating a new trigger uses the edit function.
// ID == 0 - create a new trigger.
// ID > 0 - edit existing trigger.
var trigger Trigger var trigger Trigger
trigger, err = TriggerRetrieve(id) if id > 0 {
if err != nil { trigger, err = TriggerRetrieve(id)
httpError(w, we.Wrap(err).Log()) if err != nil {
return 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) 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()) httpError(w, we.Wrap(err).Log())
return return
} }
dp.LastDatapointValue.TemplateValue = dp.LastDatapointValue.Value()
datapoints[dpname] = dp datapoints[dpname] = dp
} }
@ -440,14 +469,23 @@ func pageTriggerUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { /
} }
var trigger Trigger var trigger Trigger
trigger, err = TriggerRetrieve(id) if id > 0 {
if err != nil { trigger, err = TriggerRetrieve(id)
httpError(w, we.Wrap(err).Log()) if err != nil {
return 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.Name = r.FormValue("name")
trigger.Expression = r.FormValue("expression") trigger.Expression = r.FormValue("expression")
trigger.Datapoints = r.Form["datapoints[]"]
err = trigger.Update() err = trigger.Update()
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) 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.Header().Add("Content-Type", "application/json")
w.Write(j) w.Write(j)
} // }}} } // }}}
func pageConfiguration(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ func pageConfiguration(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
areas, err := AreaRetrieve() areas, err := AreaRetrieve()
if err != nil { if err != nil {

View File

@ -52,7 +52,6 @@ func (p *Page) Render(w http.ResponseWriter) {
"Data": p.Data, "Data": p.Data,
} }
logger.Info("foo", "tmpl", tmpl)
err = tmpl.Execute(w, data) err = tmpl.Execute(w, data)
if err != nil { if err != nil {
httpError(w, we.Wrap(err).Log()) httpError(w, we.Wrap(err).Log())

1
sql/00008.sql Normal file
View File

@ -0,0 +1 @@
ALTER TABLE public."trigger" ADD CONSTRAINT trigger_sectionname_unique UNIQUE (section_id,"name");

View File

@ -37,7 +37,7 @@ h2 {
font-size: 1.25em; font-size: 1.25em;
} }
a { a {
color: #3f9da1; color: #fabd2f;
text-decoration: none; text-decoration: none;
} }
a:hover { a:hover {
@ -70,7 +70,7 @@ button {
background: #202020; background: #202020;
color: #d5c4a1; color: #d5c4a1;
padding: 8px 32px; padding: 8px 32px;
border: 1px solid #3a3a3a; border: 1px solid #535353;
font-size: 1em; font-size: 1em;
height: 3em; height: 3em;
} }

View File

@ -37,7 +37,7 @@ h2 {
font-size: 1.25em; font-size: 1.25em;
} }
a { a {
color: #3f9da1; color: #fabd2f;
text-decoration: none; text-decoration: none;
} }
a:hover { a:hover {
@ -70,7 +70,7 @@ button {
background: #202020; background: #202020;
color: #d5c4a1; color: #d5c4a1;
padding: 8px 32px; padding: 8px 32px;
border: 1px solid #3a3a3a; border: 1px solid #535353;
font-size: 1em; font-size: 1em;
height: 3em; height: 3em;
} }
@ -137,12 +137,17 @@ button:focus {
#areas .area .section { #areas .area .section {
margin: 8px 16px; 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; font-weight: 500;
} }
#areas .area .section .triggers a { #areas .area .section > .name {
color: inherit; font-weight: 500;
text-decoration: none;
} }
#areas .area .section .triggers .trigger { #areas .area .section .triggers .trigger {
display: grid; display: grid;
@ -155,5 +160,11 @@ button:focus {
height: 16px; height: 16px;
} }
#areas .area .section .triggers .trigger .label { #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);
} }

View File

@ -37,7 +37,7 @@ h2 {
font-size: 1.25em; font-size: 1.25em;
} }
a { a {
color: #3f9da1; color: #fabd2f;
text-decoration: none; text-decoration: none;
} }
a:hover { a:hover {
@ -70,7 +70,7 @@ button {
background: #202020; background: #202020;
color: #d5c4a1; color: #d5c4a1;
padding: 8px 32px; padding: 8px 32px;
border: 1px solid #3a3a3a; border: 1px solid #535353;
font-size: 1em; font-size: 1em;
height: 3em; height: 3em;
} }

View File

@ -37,7 +37,7 @@ h2 {
font-size: 1.25em; font-size: 1.25em;
} }
a { a {
color: #3f9da1; color: #fabd2f;
text-decoration: none; text-decoration: none;
} }
a:hover { a:hover {
@ -70,7 +70,7 @@ button {
background: #202020; background: #202020;
color: #d5c4a1; color: #d5c4a1;
padding: 8px 32px; padding: 8px 32px;
border: 1px solid #3a3a3a; border: 1px solid #535353;
font-size: 1em; font-size: 1em;
height: 3em; height: 3em;
} }

View File

@ -1,17 +1,35 @@
export class UI { export class UI {
constructor() { constructor() {//{{{
document.getElementById('button-run'). document.getElementById('button-run').
addEventListener('click', evt=>evt.preventDefault()) addEventListener('click', evt => evt.preventDefault())
document.addEventListener('keydown', evt=>this.keyHandler(evt)) document.addEventListener('keydown', evt => this.keyHandler(evt))
}
setTrigger(t) { 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 `
<div class="datapoint name"><b>${dp.Name}</b></div>
<div class="datapoint value">${dp.LastDatapointValue.TemplateValue}</div>
`
}).join('')
datapoints.innerHTML += html
}//}}}
setTrigger(t) {//{{{
this.trigger = t this.trigger = t
} }//}}}
run() { run() {//{{{
this.trigger.run() this.trigger.run()
} }//}}}
keyHandler(evt) { keyHandler(evt) {//{{{
if (!(evt.altKey && evt.shiftKey)) if (!(evt.altKey && evt.shiftKey))
return return
@ -21,21 +39,75 @@ export class UI {
switch (evt.key) { switch (evt.key) {
case 'T': case 'T':
this.run() this.run()
break break
case 'S': case 'S':
document.getElementById('form-trigger').submit() this.update()
break 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 += `<option value="${dp.Name}">`
})
datalist.innerHTML = html
})
.catch(err => alert(err))
}//}}}
chooseDatapoint() {//{{{
const dlg = document.getElementById('dlg-datapoints')
const datapoint = document.getElementById('datapoint').value
const dp = this.datapoints.find(dp => dp.Name == datapoint)
if (dp === undefined) {
alert('Invalid datapoint')
return
}
this.trigger.addDatapoint(dp)
dlg.close()
this.render()
}//}}}
update() {//{{{
const form = document.getElementById('form-trigger')
var formData = new FormData(form)
Object.keys(this.trigger.datapoints).forEach(name => formData.append("datapoints[]", name))
fetch(form.action, {
method: 'POST',
body: formData,
})
.then(resp => {
if (resp.redirected) {
location.href = resp.url
return
}
return resp.json()
})
.then(json => {
if (json)
alert(json.Error)
})
.catch(err => alert(err))
}//}}}
} }
export class Trigger { export class Trigger {
constructor(id, name) { constructor(id, name, datapoints) {//{{{
this.id = id this.id = id
this.name = name this.name = name
} this.datapoints = datapoints
run() { }//}}}
run() {//{{{
const result = document.getElementById('run-result') const result = document.getElementById('run-result')
const classes = result.classList const classes = result.classList
const expr = document.getElementById('expr').value const expr = document.getElementById('expr').value
@ -59,5 +131,8 @@ export class Trigger {
result.innerText = json.Output result.innerText = json.Output
}) })
.catch(err => alert(err)) .catch(err => alert(err))
} }//}}}
addDatapoint(dp) {//{{{
this.datapoints[dp.Name] = dp
}//}}}
} }

View File

@ -71,17 +71,22 @@
.section { .section {
margin: 8px 16px; margin: 8px 16px;
.create {
display: grid;
grid-template-columns: min-content min-content;
grid-gap: 8px;
white-space: nowrap;
.new {
font-weight: @bold;
}
}
&>.name { &>.name {
font-weight: 500; font-weight: @bold;
} }
.triggers { .triggers {
a {
color: inherit;
text-decoration: none;
}
.trigger { .trigger {
display: grid; display: grid;
grid-template-columns: min-content 1fr; grid-template-columns: min-content 1fr;
@ -94,10 +99,17 @@
} }
.label { .label {
color: @color4; color: inherit;
} }
} }
} }
} }
} }
} }
dialog {
background: @bg2;
border: 1px solid lighten(@bg2, 25%);
color: @text1;
box-shadow: 10px 10px 15px 0px rgba(0, 0, 0, 0.25);
}

View File

@ -59,7 +59,7 @@ h2 {
} }
a { a {
color: @color4; color: @color2;
text-decoration: none; text-decoration: none;
&:hover { &:hover {
@ -96,7 +96,7 @@ button {
background: @bg2; background: @bg2;
color: @text1; color: @text1;
padding: 8px 32px; padding: 8px 32px;
border: 1px solid lighten(@bg2, 10%); border: 1px solid lighten(@bg2, 20%);
font-size: 1em; font-size: 1em;
height: 3em; height: 3em;

View File

@ -1,5 +1,8 @@
@import "theme.less"; @import "theme.less";
#dlg-datapoints {
}
.widgets { .widgets {
display: grid; display: grid;
grid-template-columns: min-content 1fr; grid-template-columns: min-content 1fr;

View File

@ -4,6 +4,7 @@ import (
// External // External
we "git.gibonuddevalla.se/go/wrappederror" we "git.gibonuddevalla.se/go/wrappederror"
"github.com/expr-lang/expr" "github.com/expr-lang/expr"
"github.com/lib/pq"
// Standard // Standard
"encoding/json" "encoding/json"
@ -19,9 +20,6 @@ type Trigger struct {
Datapoints []string Datapoints []string
} }
func Foo() {
}
func TriggersRetrieve() (areas []Area, err error) { // {{{ func TriggersRetrieve() (areas []Area, err error) { // {{{
areas = []Area{} areas = []Area{}
@ -130,21 +128,50 @@ func (t *Trigger) Update() (err error) { // {{{
return return
} }
logger.Info("FOO", "trigger", t) if t.Datapoints == nil {
_, err = service.Db.Conn.Exec(` t.Datapoints = []string{}
UPDATE "trigger" }
SET jsonDatapoints, _ := json.Marshal(t.Datapoints)
name=$2, if t.ID == 0 {
expression=$3 _, err = service.Db.Conn.Exec(`
WHERE INSERT INTO "trigger"(name, section_id, expression, datapoints)
id=$1 VALUES($1, $2, $3, $4)
`, `,
t.ID, t.Name,
t.Name, t.SectionID,
t.Expression, t.Expression,
) jsonDatapoints,
if err != nil { )
err = we.Wrap(err) } else {
_, err = service.Db.Conn.Exec(`
UPDATE "trigger"
SET
name=$2,
expression=$3,
datapoints=$4
WHERE
id=$1
`,
t.ID,
t.Name,
t.Expression,
jsonDatapoints,
)
}
if pqErr, ok := err.(*pq.Error); ok {
err = we.Wrap(err).WithData(
struct {
Trigger *Trigger
PostgresCode pq.ErrorCode
PostgresMsg string
}{
t,
pqErr.Code,
pqErr.Code.Name(),
})
} else if err != nil {
err = we.Wrap(err).WithData(t)
} }
return return
} // }}} } // }}}

View File

@ -6,34 +6,42 @@
let trigger = new Trigger( let trigger = new Trigger(
{{ .Data.Trigger.ID }}, {{ .Data.Trigger.ID }},
'{{ .Data.Trigger.Name }}', '{{ .Data.Trigger.Name }}',
{{ .Data.Datapoints }},
) )
_ui.setTrigger(trigger) _ui.setTrigger(trigger)
_ui.render()
</script> </script>
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/trigger_edit.css"> <link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/trigger_edit.css">
{{ block "page_label" . }}{{end}} {{ block "page_label" . }}{{end}}
<dialog id="dlg-datapoints">
<input list="list-datapoints" id="datapoint">
<datalist id="list-datapoints"></datalist>
<button onclick="_ui.chooseDatapoint()">OK</button>
</dialog>
<form id="form-trigger" action="/trigger/update/{{ .Data.Trigger.ID }}" method="post"> <form id="form-trigger" action="/trigger/update/{{ .Data.Trigger.ID }}" method="post">
<div id="widgets" class="widgets"> <input type="hidden" name="sectionID" value="{{ .Data.Trigger.SectionID }}">
<div class="label">Name</div> <div id="widgets" class="widgets">
<div><input type="text" name="name" value="{{ .Data.Trigger.Name }}"></div> <div class="label">Name</div>
<div><input type="text" name="name" value="{{ .Data.Trigger.Name }}"></div>
<div class="label">Datapoints</div> <div class="label">Datapoints</div>
<div class="datapoints" style="margin-top: 4px"> <div class="datapoints" style="margin-top: 4px">
{{ range .Data.Datapoints }} <div><a onclick="_ui.addDatapoint()">Add</a></div>
<div class="datapoint name"><b>{{ .Name }}</b></div> <div></div>
<div class="datapoint value">{{ .LastDatapointValue.Value }}</div> </div>
{{ end }}
</div>
<div class="label">Expression</div>
<div><textarea id="expr" name="expression" rows=8>{{ .Data.Trigger.Expression }}</textarea></div>
<div></div> <div class="label">Expression</div>
<div class="action"> <div><textarea id="expr" name="expression" rows=8>{{ .Data.Trigger.Expression }}</textarea></div>
<button id="button-update">Update</button>
<button id="button-run" onclick="window._ui.run(); return false">Test</button> <div></div>
<div id="run-result"></div> <div class="action">
<button id="button-update" onclick="_ui.update(); return false">{{ if eq .Data.Trigger.ID 0 }}Create{{ else }}Update{{ end }}</button>
<button id="button-run" onclick="window._ui.run(); return false">Test</button>
<div id="run-result"></div>
</div>
</div> </div>
</form> </form>

View File

@ -9,7 +9,10 @@
<div class="name">{{ .Name }}</div> <div class="name">{{ .Name }}</div>
{{ range .SortedSections }} {{ range .SortedSections }}
<div class="section"> <div class="section">
<div class="name">{{ .Name }}</div> <div class="create">
<div class="name">{{ .Name }}</div>
<div class="new"><a href="/trigger/edit/0/{{ .ID }}">+</a></div>
</div>
<div class="triggers"> <div class="triggers">
{{ range .SortedTriggers }} {{ range .SortedTriggers }}