Compare commits

...

13 Commits
v36 ... main

Author SHA1 Message Date
Magnus Åhall
8fa8bb4c3a Removed datapoint rename confirmation since everything is automatic 2024-07-25 10:59:03 +02:00
Magnus Åhall
7cf2b60803 Bumped to v41 2024-07-25 10:57:05 +02:00
Magnus Åhall
db41d360dc #6, update trigger expressions when renaming datapoints 2024-07-25 10:56:42 +02:00
Magnus Åhall
6685f8bc46 Bumped to v40 2024-07-25 09:39:48 +02:00
Magnus Åhall
96f7b50e4e UI confirm of datapoint renaming 2024-07-25 09:38:43 +02:00
Magnus Åhall
8ef6a2bbfa Added trigger list to datapoint edit 2024-07-25 08:38:06 +02:00
Magnus Åhall
d1599fe2b9 Fixed trigger edit regression and bumped to v39 2024-07-24 16:05:30 +02:00
Magnus Åhall
6909b223a7 Bumped to v38 2024-07-24 15:39:33 +02:00
Magnus Åhall
570ea064aa Faster datapoint latest value display 2024-07-24 15:39:04 +02:00
Magnus Åhall
f8a64e4dfd Added last values for table datapoint, update to current latest values and trigger for future values 2024-07-24 10:27:21 +02:00
Magnus Åhall
a8bdeae3a9 Trying NTFY priority of 5 to get better Firebase notifications 2024-07-19 10:11:53 +02:00
Magnus Åhall
1deb80c776 Bumped to v37 2024-07-07 15:52:08 +02:00
Magnus Åhall
85a6da0b0a Added link to datapoint values from trigger 2024-07-07 15:51:51 +02:00
12 changed files with 259 additions and 76 deletions

View File

@ -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
}

56
main.go
View File

@ -29,7 +29,7 @@ import (
"time"
)
const VERSION = "v36"
const VERSION = "v41"
var (
logger *slog.Logger
@ -663,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",
@ -674,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
@ -689,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"))
@ -702,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)
} // }}}

View File

@ -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)

62
sql/00026.sql Normal file
View File

@ -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();

22
sql/00027.sql Normal file
View File

@ -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();

View File

@ -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;

View File

@ -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;

View File

@ -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()
}

View File

@ -19,9 +19,10 @@ export class UI {
let html = Object.keys(this.trigger.datapoints).sort().map(dpName => {
const dp = this.trigger.datapoints[dpName]
return `
<div class="datapoint delete"><a href="#" onclick="_ui.deleteDatapoint('${dp.Name}')"><img src="/images/${this.version}/${this.theme}/delete.svg"></a></div>
<div class="datapoint name ${dp.Found ? 'valid' : 'invalid'}"><b>${dp.Name}</b></div>
<div class="datapoint value">${dp.Found ? dp.LastDatapointValue.TemplateValue : ''}</div>
<div class="daatpoint values"><a href="/datapoint/values/${dp.ID}"><img src="/images/${this.version}/${this.theme}/values.svg"></a></div>
<div class="datapoint delete"><a href="#" onclick="_ui.deleteDatapoint('${dp.Name}')"><img src="/images/${this.version}/${this.theme}/delete.svg"></a></div>
`
}).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
}

View File

@ -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 {

View File

@ -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
@ -22,6 +24,17 @@ type Trigger struct {
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) { // {{{
t.SectionID = sectionID
t.Name = name
@ -127,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")
@ -212,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 {
@ -248,3 +261,14 @@ func (t *Trigger) Run() (output any, err error) { // {{{
}
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
} // }}}

View File

@ -3,48 +3,59 @@
<script type="module" defer>
import {UI} from "/js/{{ .VERSION }}/datapoint_edit.mjs"
window._ui = new UI()
window._ui = new UI({{ .Data.Datapoint }})
</script>
{{ block "page_label" . }}{{end}}
<form id="form-trigger" action="/datapoint/update/{{ .Data.Datapoint.ID }}" method="post">
<div id="widgets" class="widgets">
<div class="label">Group</div>
<div><input type="text" name="group" value="{{ .Data.Datapoint.Group }}"></div>
<div id="widgets" class="widgets">
<div class="label">Group</div>
<div><input type="text" name="group" value="{{ .Data.Datapoint.Group }}"></div>
<div class="label">Name</div>
<div><input type="text" name="name" value="{{ .Data.Datapoint.Name }}"></div>
<div class="label">Datatype</div>
<div>
<select name="datatype">
<option {{ if eq .Data.Datapoint.Datatype "INT" }}selected{{end}}>INT</option>
<option {{ if eq .Data.Datapoint.Datatype "STRING" }}selected{{end}}>STRING</option>
<option {{ if eq .Data.Datapoint.Datatype "DATETIME" }}selected{{end}}>DATETIME</option>
</select>
</div>
<div class="label">Name</div>
<div><input type="text" name="name" value="{{ .Data.Datapoint.Name }}"></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 class="label">Datatype</div>
<div>
<select name="datatype">
<option {{ if eq .Data.Datapoint.Datatype "INT" }}selected{{end}}>INT</option>
<option {{ if eq .Data.Datapoint.Datatype "STRING" }}selected{{end}}>STRING</option>
<option {{ if eq .Data.Datapoint.Datatype "DATETIME" }}selected{{end}}>DATETIME</option>
</select>
</div>
<div class="label">Comment</div>
<div>
<textarea name="comment" rows=4>{{ .Data.Datapoint.Comment }}</textarea>
</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 class="label">Comment</div>
<div>
<textarea name="comment" rows=4>{{ .Data.Datapoint.Comment }}</textarea>
</div>
<div></div>
<div class="action">
{{ if eq .Data.Datapoint.ID 0 }}
<button id="button-update">Create</button>
{{ else }}
<button id="button-update">Update</button>
{{ end }}
<div></div>
<div class="action">
{{ if eq .Data.Datapoint.ID 0 }}
<button id="button-update">Create</button>
{{ else }}
<button id="button-update">Update</button>
{{ end }}
</div>
<div></div>
<div style="margin-top: 32px">
<b>Used in the following triggers:</b>
<ul>
{{ range .Data.Triggers }}
<li><a href="/trigger/edit/{{ .ID }}">{{ .Name }}</a></li>
{{ end }}
</ul>
</div>
</div>
</form>