View datapoint values

This commit is contained in:
Magnus Åhall 2024-05-05 20:16:28 +02:00
parent 5f6a48e7e0
commit d72694a8b4
14 changed files with 288 additions and 28 deletions

View File

@ -2,7 +2,7 @@ package main
import (
// External
we "git.gibonuddevalla.se/go/wrappederror"
werr "git.gibonuddevalla.se/go/wrappederror"
"github.com/jmoiron/sqlx"
// Standard
@ -94,7 +94,7 @@ func DatapointAdd[T any](name string, value T) (err error) { // {{{
err = row.Scan(&dpID, &dpType)
if err != nil {
err = we.Wrap(err).WithData(struct {
err = werr.Wrap(err).WithData(struct {
Name string
Value any
}{name, value})
@ -110,7 +110,7 @@ func DatapointAdd[T any](name string, value T) (err error) { // {{{
_, err = service.Db.Conn.Exec(`INSERT INTO datapoint_value(datapoint_id, value_datetime) VALUES($1, $2)`, dpID, value)
}
if err != nil {
err = we.Wrap(err).WithData(struct {
err = werr.Wrap(err).WithData(struct {
ID int
value any
}{dpID, value})
@ -147,7 +147,7 @@ func DatapointsRetrieve() (dps []Datapoint, err error) { // {{{
dp.name ASC
`)
if err != nil {
err = we.Wrap(err)
err = werr.Wrap(err)
}
defer rows.Close()
@ -170,7 +170,7 @@ func DatapointsRetrieve() (dps []Datapoint, err error) { // {{{
res := DbRes{}
err = rows.StructScan(&res)
if err != nil {
err = we.Wrap(err)
err = werr.Wrap(err)
return
}
@ -218,7 +218,7 @@ func DatapointRetrieve(id int, name string) (dp Datapoint, err error) { // {{{
}
if err != nil {
err = we.Wrap(err).WithData(name)
err = werr.Wrap(err).WithData(name)
return
}
@ -238,7 +238,7 @@ func DatapointRetrieve(id int, name string) (dp Datapoint, err error) { // {{{
}
if err != nil {
err = we.Wrap(err).WithData(dp.ID)
err = werr.Wrap(err).WithData(dp.ID)
return
}
@ -247,7 +247,26 @@ func DatapointRetrieve(id int, name string) (dp Datapoint, err error) { // {{{
func DatapointDelete(id int) (err error) {// {{{
_, err = service.Db.Conn.Exec(`DELETE FROM datapoint WHERE id=$1`, id)
if err != nil {
err = we.Wrap(err).WithData(id)
err = werr.Wrap(err).WithData(id)
}
return
}// }}}
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)
return
}
defer rows.Close()
for rows.Next() {
dpv := DatapointValue{}
err = rows.StructScan(&dpv)
if err != nil {
err = werr.Wrap(err).WithData(id)
return
}
values = append(values, dpv)
}
return
}// }}}

98
main.go
View File

@ -122,6 +122,7 @@ func main() { // {{{
service.Register("/datapoint/edit/{id}", false, false, pageDatapointEdit)
service.Register("/datapoint/update/{id}", false, false, pageDatapointUpdate)
service.Register("/datapoint/delete/{id}", false, false, pageDatapointDelete)
service.Register("/datapoint/values/{id}", false, false, pageDatapointValues)
service.Register("/triggers", false, false, pageTriggers)
service.Register("/trigger/edit/{id}", false, false, pageTriggerEdit)
@ -194,6 +195,8 @@ func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { /
if err != nil {
logger.Error("entry", "error", err)
}
// Multiple triggers can use the same datapoint.
for _, trigger := range triggers {
var out any
out, err = trigger.Run()
@ -204,23 +207,77 @@ func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { /
}
logger.Debug("entry", "datapoint", dpoint, "value", value, "trigger", trigger, "result", out)
var problemID int
switch v := out.(type) {
case bool:
// Trigger returning true - a problem occurred
if v {
err = ProblemStart(trigger)
problemID, err = ProblemStart(trigger)
logger.Info("FOO", "problemID", problemID, "err==nil", err == nil)
if err != nil {
err = werr.Wrap(err).Log()
logger.Error("entry", "error", err)
}
} else {
err = ProblemClose(trigger)
// A problem didn't occur.
problemID, err = ProblemClose(trigger)
logger.Info("FOO", "problemID", problemID, "err==nil", err == nil)
if err != nil {
err = werr.Wrap(err).Log()
logger.Error("entry", "error", err)
}
}
// Has a change in problem state happened?
if problemID == 0 && err == nil {
logger.Debug("notification", "trigger", trigger.ID, "state", "no change")
continue
} else {
logger.Debug("notification", "trigger", trigger.ID, "state", "change")
}
err = notificationManager.Send(problemID, []byte(trigger.Name), func(notificationService *notification.Service, err error) {
logger.Info(
"notification",
"service", (*notificationService).GetType(),
"problemID", problemID,
"prio", (*notificationService).GetPrio(),
"ok", true,
)
var errBody any
if err != nil {
errBody, _ = json.Marshal(err)
} else {
errBody = nil
}
_, err = service.Db.Conn.Exec(
`
INSERT INTO notification_send(notification_id, problem_id, uuid, ok, error)
SELECT
id, $3, '', $4, $5
FROM notification
WHERE
service=$1 AND
prio=$2
`,
(*notificationService).GetType(),
(*notificationService).GetPrio(),
problemID,
err == nil,
errBody,
)
if err != nil {
err = werr.Wrap(err).Log()
logger.Error("entry", "error", err)
}
})
if err != nil {
err = werr.Wrap(err).Log()
logger.Error("notification", "error", err)
}
default:
err := fmt.Errorf(`Expression for trigger %s not returning bool (%T)`, trigger.Name, v)
logger.Info("entry", "error", err)
@ -519,6 +576,43 @@ func pageDatapointDelete(w http.ResponseWriter, r *http.Request, _ *session.T) {
w.Header().Add("Location", "/datapoints")
w.WriteHeader(302)
} // }}}
func pageDatapointValues(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
idStr := r.PathValue("id")
id, err := strconv.Atoi(idStr)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
var datapoint Datapoint
datapoint, err = DatapointRetrieve(id, "")
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
var values []DatapointValue
values, err = DatapointValues(id)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
page := Page{
LAYOUT: "main",
PAGE: "datapoint_values",
MENU: "datapoints",
Icon: "datapoints",
Label: "Values for "+datapoint.Name,
}
page.Data = map[string]any{
"Datapoint": datapoint,
"Values": values,
}
page.Render(w)
return
} // }}}
func pageTriggers(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
areas, err := TriggersRetrieve()

View File

@ -44,7 +44,7 @@ func (ntfy *NTFY) GetPrio() int {
return ntfy.Prio
}
func (ntfy NTFY) Send(uuid string, msg []byte) (err error) {
func (ntfy NTFY) Send(problemID int, msg []byte) (err error) {
var req *http.Request
var res *http.Response
req, err = http.NewRequest("POST", ntfy.URL, bytes.NewReader(msg))
@ -53,9 +53,9 @@ func (ntfy NTFY) Send(uuid string, msg []byte) (err error) {
return
}
ackURL := fmt.Sprintf("http, OK, %s/notification/ack?uuid=%s", ntfy.AcknowledgeURL, uuid)
ackURL := fmt.Sprintf("http, OK, %s/notification/ack?problemID=%d", ntfy.AcknowledgeURL, problemID)
req.Header.Add("X-Actions", ackURL)
req.Header.Add("X-Priority", "3") // XXX: should be 5
req.Header.Add("X-Priority", "4") // XXX: should be 5
req.Header.Add("X-Tags", "calendar")
res, err = http.DefaultClient.Do(req)

View File

@ -13,7 +13,7 @@ type Service interface {
SetLogger(*slog.Logger)
GetPrio() int
GetType() string
Send(string, []byte) error
Send(int, []byte) error
}
type Manager struct {
@ -40,20 +40,22 @@ func (nm *Manager) AddService(service Service) {
})
}
func (nm *Manager) Send(uuid string, msg []byte) (err error) {
for _, service := range nm.services {
func (nm *Manager) Send(problemID int, msg []byte, fn func(*Service, error)) (err error) {
for i, service := range nm.services {
nm.logger.Info("notification", "service", service.GetType(), "prio", service.GetPrio())
if err = service.Send(uuid, msg); err == nil {
if err = service.Send(problemID, msg); err == nil {
fn(&nm.services[i], nil)
break
} else {
data := struct {
UUID string
ProblemID int
Msg []byte
}{
uuid,
problemID,
msg,
}
werr.Wrap(err).WithData(data).Log()
fn(&nm.services[i], err)
}
}

20
notification_log.go Normal file
View File

@ -0,0 +1,20 @@
package main
import (
// Internal
"smon/notification"
// Standard
)
func notificationLog(notificationService *notification.Service, problemID int, err error) {
if err == nil {
logger.Info("notification", "service", (*notificationService).GetType(), "problemID", problemID, "prio", (*notificationService).GetPrio(), "ok", true)
service.Db.Conn.Query(
`
INSERT INTO notification_send()
`,
)
} else {
logger.Error("notification", "service", (*notificationService).GetType(), "problemID", problemID, "prio", (*notificationService).GetPrio(), "ok", false, "error", err)
}
}

View File

@ -66,7 +66,7 @@ func ProblemsRetrieve() (problems []Problem, err error) {
return
}
func ProblemStart(trigger Trigger) (err error) {
func ProblemStart(trigger Trigger) (problemID int, err error) {
row := service.Db.Conn.QueryRow(`
SELECT COUNT(id)
FROM problem
@ -86,7 +86,8 @@ func ProblemStart(trigger Trigger) (err error) {
// 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)
row = service.Db.Conn.QueryRow(`INSERT INTO problem(trigger_id) VALUES($1) RETURNING id`, trigger.ID)
err = row.Scan(&problemID)
if err != nil {
err = we.Wrap(err).WithData(trigger)
}
@ -94,8 +95,15 @@ func ProblemStart(trigger Trigger) (err error) {
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)
func ProblemClose(trigger Trigger) (problemID int, err error) {
row := service.Db.Conn.QueryRow(`UPDATE problem SET "end"=NOW() WHERE trigger_id=$1 AND "end" IS NULL RETURNING id`, trigger.ID)
err = row.Scan(&problemID)
if err == sql.ErrNoRows {
err = nil
return
}
if err != nil {
err = we.Wrap(err).WithData(trigger)
return

13
sql/00012.sql Normal file
View File

@ -0,0 +1,13 @@
CREATE TABLE public.notification_send (
id serial NOT NULL,
notification_id int4 NOT NULL,
"uuid" char(36) NOT NULL,
send timestamptz DEFAULT now() NOT NULL,
ok bool NOT NULL,
error jsonb NULL,
acknowledged bool DEFAULT false NOT NULL,
problem_id int8 NOT NULL,
CONSTRAINT notification_send_pk PRIMARY KEY (id),
CONSTRAINT notification_send_notification_fk FOREIGN KEY (notification_id) REFERENCES public.notification(id) ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT notification_send_problem_fk FOREIGN KEY (problem_id) REFERENCES public.problem(id) ON DELETE CASCADE ON UPDATE CASCADE
);

View File

@ -109,6 +109,17 @@ label {
#datapoints div {
white-space: nowrap;
}
#datapoints .icons {
display: flex;
gap: 12px;
align-items: center;
}
#values {
display: grid;
grid-template-columns: repeat(2, min-content);
gap: 16px;
white-space: nowrap;
}
.widgets {
display: grid;
grid-template-columns: min-content 1fr;

View File

@ -101,7 +101,6 @@ label {
display: grid;
grid-template-areas: "menu content";
grid-template-columns: 64px 1fr;
grid-template-rows: 100% 100%;
height: 100vh;
}
#menu {

67
static/images/values.svg Normal file
View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="18.500002"
height="15"
viewBox="0 0 4.8947921 3.9687501"
version="1.1"
id="svg8"
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
sodipodi:docname="points.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1"
inkscape:cx="-33"
inkscape:cy="-79"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1093"
inkscape:window-height="1404"
inkscape:window-x="1463"
inkscape:window-y="16"
inkscape:window-maximized="0"
inkscape:showpageshadow="true"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#d6d6d6"
showborder="true" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(-81.359375,-211.79896)">
<title
id="title1">format-list-bulleted</title>
<path
d="m 82.55,211.93125 h 3.704167 v 0.52917 H 82.55 v -0.52917 m 0,2.11667 v -0.52917 h 3.704167 v 0.52917 H 82.55 m -0.79375,-2.24896 a 0.396875,0.396875 0 0 1 0.396875,0.39687 0.396875,0.396875 0 0 1 -0.396875,0.39688 0.396875,0.396875 0 0 1 -0.396875,-0.39688 0.396875,0.396875 0 0 1 0.396875,-0.39687 m 0,1.5875 a 0.396875,0.396875 0 0 1 0.396875,0.39687 0.396875,0.396875 0 0 1 -0.396875,0.39688 0.396875,0.396875 0 0 1 -0.396875,-0.39688 0.396875,0.396875 0 0 1 0.396875,-0.39687 m 0.79375,2.24896 v -0.52917 h 3.704167 v 0.52917 H 82.55 m -0.79375,-0.66146 a 0.396875,0.396875 0 0 1 0.396875,0.39687 0.396875,0.396875 0 0 1 -0.396875,0.39688 0.396875,0.396875 0 0 1 -0.396875,-0.39688 0.396875,0.396875 0 0 1 0.396875,-0.39687 z"
id="path1"
style="font-variation-settings:normal;opacity:1;vector-effect:none;fill:#fb4934;fill-opacity:1;stroke-width:0.384845;stroke-linecap:butt;stroke-linejoin:miter;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1;-inkscape-stroke:none;stop-color:#000000;stop-opacity:1" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.8 KiB

View File

@ -13,6 +13,19 @@
div {
white-space: nowrap;
}
.icons {
display: flex;
gap: 12px;
align-items: center;
}
}
#values {
display: grid;
grid-template-columns: repeat(2, min-content);
gap: 16px;
white-space: nowrap;
}
.widgets {

View File

@ -4,8 +4,6 @@
display: grid;
grid-template-areas: "menu content";
grid-template-columns: 64px 1fr;
grid-template-rows:
100% 100%;
height: 100vh;
}

View File

@ -0,0 +1,13 @@
{{ define "page" }}
{{ $version := .VERSION }}
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/datapoints.css">
{{ block "page_label" . }}{{end}}
<div id="values">
{{ range .Data.Values }}
<div class="value">{{ format_time .Ts }}</div>
<div class="value">{{ .Value }}</div>
{{ end }}
</div>
{{ end }}

View File

@ -23,7 +23,10 @@
{{ else }}
<div class="value">{{ .LastDatapointValue.Value }}</div>
{{ end }}
<div class="icons">
<div class="values"><a href="/datapoint/values/{{ .ID }}"><img src="/images/{{ $version }}/values.svg"></a></div>
<div class="delete"><a href="/datapoint/delete/{{ .ID }}" onclick="confirm(`Are you sure you want to delete '{{ .Name }}'?`)"><img src="/images/{{ $version }}/delete.svg"></a></div>
</div>
{{ end }}
</div>
{{ end }}