diff --git a/datapoint.go b/datapoint.go index 3672033..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 { // {{{ @@ -53,13 +54,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 +83,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..60e1317 100644 --- a/main.go +++ b/main.go @@ -95,21 +95,22 @@ func main() { // {{{ logger.Error("application", "error", err) return } - _, err = service.Db.Conn.Exec(`SET TIMEZONE TO 'Europe/Stockholm'`) - if err != nil { - logger.Error("application", "error", err) - return - } service.Register("/", false, false, staticHandler) service.Register("/problems", false, false, pageProblems) + service.Register("/problem/acknowledge/{id}", false, false, pageProblemAcknowledge) + service.Register("/problem/unacknowledge/{id}", false, false, pageProblemUnacknowledge) + 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) @@ -170,6 +171,45 @@ func entryDatapoint(w http.ResponseWriter, r *http.Request, sess *session.T) { / return } + var triggers []Trigger + triggers, err = TriggersRetrieveByDatapoint(dpoint) + 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 occurred + 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) } // }}} @@ -204,8 +244,10 @@ func getPage(layout, page string) (tmpl *template.Template, err error) { // {{{ } funcMap := template.FuncMap{ - "format_time": func(t time.Time) string { - return t.Local().Format("2006-01-02 15:04:05") + "format_time": func(t time.Time) template.HTML { + return template.HTML( + t.Local().Format(`2006-01-02 15:04:05`), + ) }, } @@ -233,32 +275,65 @@ 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", PAGE: "problems", } - /* - areas, err := ProblemsRetrieve() - if err != nil { - httpError(w, we.Wrap(err).Log()) - return - } - sort.SliceStable(areas, func(i, j int) bool { - return areas[i].Name < areas[j].Name - }) - - logger.Info("problems", "areas", areas) - */ + problems, err := ProblemsRetrieve() + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } page.Data = map[string]any{ - //"Areas": areas, + "Problems": problems, } page.Render(w) return } // }}} -func pageDatapoints(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{ +func pageProblemAcknowledge(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + + err = ProblemAcknowledge(id, true) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + + w.Header().Add("Location", "/problems") + w.WriteHeader(302) + + return +} // }}} +func pageProblemUnacknowledge(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ + idStr := r.PathValue("id") + id, err := strconv.Atoi(idStr) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + + err = ProblemAcknowledge(id, false) + if err != nil { + httpError(w, we.Wrap(err).Log()) + return + } + + w.Header().Add("Location", "/problems") + w.WriteHeader(302) + + return +} // }}} + +func pageDatapoints(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ page := Page{ LAYOUT: "main", PAGE: "datapoints", @@ -269,7 +344,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, @@ -332,6 +413,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 { @@ -361,11 +443,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) @@ -375,6 +472,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 } @@ -401,14 +499,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()) @@ -436,23 +543,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) @@ -463,6 +560,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/problem.go b/problem.go index 4c7b00e..5d6f0b5 100644 --- a/problem.go +++ b/problem.go @@ -2,19 +2,108 @@ package main import ( // External - // we "git.gibonuddevalla.se/go/wrappederror" + we "git.gibonuddevalla.se/go/wrappederror" // Standard - // "encoding/json" + "database/sql" + "encoding/json" + "time" ) -/* type Problem struct { - ID int - Name string - SectionID int - Expression string - DatapointNames []string + ID int + Start time.Time + End sql.NullTime + Acknowledged bool + TriggerID int `json:"trigger_id"` + TriggerName string `json:"trigger_name"` + AreaName string `json:"area_name"` + SectionName string `json:"section_name"` } -*/ +func ProblemsRetrieve() (problems []Problem, err error) { + problems = []Problem{} + row := service.Db.Conn.QueryRow(` + SELECT + jsonb_agg(p.*) + FROM ( + SELECT + p.id, + p.start, + p.end, + p.acknowledged, + t.id AS trigger_id, + t.name AS trigger_name, + a.name AS area_name, + s.name AS section_name + FROM problem p + INNER JOIN "trigger" t ON p.trigger_id = t.id + INNER JOIN section s ON t.section_id = s.id + INNER JOIN area a ON s.area_id = a.id + + WHERE + p.end IS NULL + + ORDER BY p.start DESC + ) p + `) + + var jsonBody []byte + err = row.Scan(&jsonBody) + if err != nil { + err = we.Wrap(err) + return + } + + err = json.Unmarshal(jsonBody, &problems) + if err != nil { + err = we.Wrap(err) + } + return +} + +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 +} + +func ProblemAcknowledge(id int, state bool) (err error) { + _, err = service.Db.Conn.Exec(`UPDATE problem SET "acknowledged"=$2 WHERE id=$1`, id, state) + if err != nil { + err = we.Wrap(err).WithData(id) + return + } + return +} 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..8230ed8 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,13 +70,31 @@ button { background: #202020; color: #d5c4a1; padding: 8px 32px; - border: 1px solid #3a3a3a; + border: 1px solid #535353; font-size: 1em; height: 3em; } button:focus { background: #333; } +.line { + grid-column: 1 / -1; + border-bottom: 1px solid #4e4e4e; +} +span.date { + color: #d5c4a1; + font-weight: 500; +} +span.time { + font-size: 0.9em; + color: #d5c4a1; +} +span.seconds { + display: none; +} +label { + user-select: none; +} #datapoints { display: grid; grid-template-columns: repeat(4, min-content); @@ -102,10 +120,10 @@ button:focus { width: 100%; } .widgets .datapoints { - font: "Roboto Mono", monospace; display: grid; grid-template-columns: min-content 1fr; gap: 6px 8px; + font-family: "Roboto Mono", monospace; margin-bottom: 8px; } .widgets .action { diff --git a/static/css/main.css b/static/css/main.css index f5b869e..cf2c7e5 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,13 +70,31 @@ button { background: #202020; color: #d5c4a1; padding: 8px 32px; - border: 1px solid #3a3a3a; + border: 1px solid #535353; font-size: 1em; height: 3em; } button:focus { background: #333; } +.line { + grid-column: 1 / -1; + border-bottom: 1px solid #4e4e4e; +} +span.date { + color: #d5c4a1; + font-weight: 500; +} +span.time { + font-size: 0.9em; + color: #d5c4a1; +} +span.seconds { + display: none; +} +label { + user-select: none; +} #layout { display: grid; grid-template-areas: "menu content"; @@ -137,12 +155,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 +178,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/problems.css b/static/css/problems.css new file mode 100644 index 0000000..48d1c30 --- /dev/null +++ b/static/css/problems.css @@ -0,0 +1,124 @@ +html { + box-sizing: border-box; +} +*, +*:before, +*:after { + box-sizing: inherit; +} +*:focus { + outline: none; +} +[onClick] { + cursor: pointer; +} +html, +body { + margin: 0; + padding: 0; +} +body { + background: #282828; + font-family: "Roboto", sans-serif; + font-weight: 300; + color: #d5c4a1; + font-size: 11pt; +} +h1, +h2 { + margin-top: 0px; + margin-bottom: 4px; +} +h1 { + font-size: 1.5em; + color: #fb4934; +} +h2 { + font-size: 1.25em; +} +a { + color: #fabd2f; + text-decoration: none; +} +a:hover { + text-decoration: underline; +} +b { + 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"], +textarea, +select { + font-family: "Roboto Mono", monospace; + background: #202020; + color: #d5c4a1; + padding: 4px 8px; + border: none; + font-size: 1em; +} +button { + background: #202020; + color: #d5c4a1; + padding: 8px 32px; + border: 1px solid #535353; + font-size: 1em; + height: 3em; +} +button:focus { + background: #333; +} +.line { + grid-column: 1 / -1; + border-bottom: 1px solid #4e4e4e; +} +span.date { + color: #d5c4a1; + font-weight: 500; +} +span.time { + font-size: 0.9em; + color: #d5c4a1; +} +span.seconds { + display: none; +} +label { + user-select: none; +} +#problems-list, +#acknowledged-list { + display: grid; + grid-template-columns: repeat(5, min-content); + grid-gap: 4px 16px; + margin-bottom: 32px; +} +#problems-list div, +#acknowledged-list div { + white-space: nowrap; +} +#problems-list .header, +#acknowledged-list .header { + font-weight: 500; +} +#problems-list .trigger, +#acknowledged-list .trigger { + color: #fb4934; + font-weight: 500; +} +#problems-list .acknowledge img, +#acknowledged-list .acknowledge img { + height: 16px; +} +#acknowledged-list.hidden { + display: none; +} diff --git a/static/css/theme.css b/static/css/theme.css index 6191e90..bfc6ab6 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,10 +70,28 @@ button { background: #202020; color: #d5c4a1; padding: 8px 32px; - border: 1px solid #3a3a3a; + border: 1px solid #535353; font-size: 1em; height: 3em; } button:focus { background: #333; } +.line { + grid-column: 1 / -1; + border-bottom: 1px solid #4e4e4e; +} +span.date { + color: #d5c4a1; + font-weight: 500; +} +span.time { + font-size: 0.9em; + color: #d5c4a1; +} +span.seconds { + display: none; +} +label { + user-select: none; +} diff --git a/static/css/trigger_edit.css b/static/css/trigger_edit.css index 41e14b4..0beff8f 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,13 +70,31 @@ button { background: #202020; color: #d5c4a1; padding: 8px 32px; - border: 1px solid #3a3a3a; + border: 1px solid #535353; font-size: 1em; height: 3em; } button:focus { background: #333; } +.line { + grid-column: 1 / -1; + border-bottom: 1px solid #4e4e4e; +} +span.date { + color: #d5c4a1; + font-weight: 500; +} +span.time { + font-size: 0.9em; + color: #d5c4a1; +} +span.seconds { + display: none; +} +label { + user-select: none; +} .widgets { display: grid; grid-template-columns: min-content 1fr; diff --git a/static/images/acknowledge-filled.svg b/static/images/acknowledge-filled.svg new file mode 100644 index 0000000..f2d8a57 --- /dev/null +++ b/static/images/acknowledge-filled.svg @@ -0,0 +1,67 @@ + + + + diff --git a/static/images/acknowledge-outline.svg b/static/images/acknowledge-outline.svg new file mode 100644 index 0000000..7e97bfb --- /dev/null +++ b/static/images/acknowledge-outline.svg @@ -0,0 +1,69 @@ + + + + diff --git a/static/js/problems.mjs b/static/js/problems.mjs new file mode 100644 index 0000000..54c4e3c --- /dev/null +++ b/static/js/problems.mjs @@ -0,0 +1,23 @@ +export class UI { + constructor() { + const showAcked = localStorage.getItem('show_acknowledged') + if (showAcked == 'true') { + document.getElementById('show-acked').checked = true + const list = document.getElementById('acknowledged-list') + list.classList.remove('hidden') + } + + } + + toggleAcknowledged(evt) { + const list = document.getElementById('acknowledged-list') + + if (evt.target.checked) { + list.classList.remove('hidden') + localStorage.setItem('show_acknowledged', true) + } else { + list.classList.add('hidden') + localStorage.setItem('show_acknowledged', false) + } + } +} 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 ` +