Compare commits

...

15 Commits

Author SHA1 Message Date
Magnus Åhall
ff402b8fcb Bumped to v22 2024-06-27 17:33:18 +02:00
Magnus Åhall
0eb945f3e0 Nicer looking value selector 2024-06-27 17:32:47 +02:00
Magnus Åhall
50664ca965 Better interactive graph support. 2024-06-27 16:53:30 +02:00
Magnus Åhall
16b4bd53f4 Return ts in selected time zone offset. 2024-06-27 16:21:16 +02:00
Magnus Åhall
530204c1a5 Refactored timeparsing from HTML input 2024-06-27 13:45:01 +02:00
Magnus Åhall
ab87da256c Stricter datetime input 2024-06-27 13:14:37 +02:00
Magnus Åhall
3109124a88 Fixed error when adding data to a datapoint without triggers. 2024-06-27 10:33:26 +02:00
Magnus Åhall
22f6b6a413 Add update of timezone setting 2024-06-27 10:02:11 +02:00
Magnus Åhall
65c0984348 Added page layout errors 2024-06-27 09:51:52 +02:00
Magnus Åhall
4b21b0ac07 Graph CSS fixes 2024-06-27 09:14:02 +02:00
Magnus Åhall
617e025be4 Clarified file configuration naming 2024-06-27 09:09:47 +02:00
Magnus Åhall
c28c848b95 Added TIMEZONE to database app config and validate 2024-06-27 09:07:02 +02:00
Magnus Åhall
43d8938459 Added datavalue filtering on date 2024-06-27 08:59:34 +02:00
Magnus Åhall
b6e1139e8a Only show graphs on INT datatype 2024-06-26 12:01:47 +02:00
Magnus Åhall
f0a6ce7b95 Basic graph for INT values 2024-06-26 11:58:54 +02:00
24 changed files with 611 additions and 50 deletions

View File

@ -1,6 +1,6 @@
package main
type SmonConfiguration struct {
type FileConfiguration struct {
LogFile string
NodataInterval int `json:"nodata_interval"` // in seconds
}

View File

@ -6,16 +6,18 @@ import (
// Standard
"database/sql"
"time"
)
type Configuration struct {
timezoneLocation *time.Location
Settings map[string]string
}
var smonConfig Configuration
func SmonConfigInit() (err error) {
smonConfig.Settings = make(map[string]string, 8)
func SmonConfigInit() (cfg Configuration, err error) {
cfg.Settings = make(map[string]string, 8)
var rows *sql.Rows
rows, err = service.Db.Conn.Query(`SELECT * FROM public.configuration`)
@ -33,14 +35,49 @@ func SmonConfigInit() (err error) {
return
}
smonConfig.Settings[setting] = value
cfg.Settings[setting] = value
}
err = cfg.LoadTimezone()
return
}
func (cfg *Configuration) Validate() (err error) {
mandatorySettings := []string{"THEME", "TIMEZONE"}
for _, settingsKey := range mandatorySettings {
if _, found := cfg.Settings[settingsKey]; !found {
return werr.New("Configuration missing setting '%s' in database", settingsKey)
}
}
return
}
func (cfg *Configuration) LoadTimezone() (err error) {
cfg.timezoneLocation, err = time.LoadLocation(cfg.Settings["TIMEZONE"])
return
}
func (cfg *Configuration) Timezone() *time.Location {
return cfg.timezoneLocation
}
func (cfg *Configuration) SetTheme(theme string) (err error) {
cfg.Settings["THEME"] = theme
_, err = service.Db.Conn.Exec(`UPDATE public.configuration SET value=$1 WHERE setting='THEME'`, theme)
return
}
func (cfg *Configuration) SetTimezone(tz string) (err error) {
cfg.Settings["TIMEZONE"] = tz
err = cfg.LoadTimezone()
if err != nil {
return werr.Wrap(err).WithData(tz)
}
_, err = service.Db.Conn.Exec(`UPDATE public.configuration SET value=$1 WHERE setting='TIMEZONE'`, tz)
if err != nil {
return werr.Wrap(err).WithData(tz)
}
return
}

View File

@ -120,8 +120,12 @@ func (dp Datapoint) Update() (err error) { // {{{
} // }}}
func DatapointAdd[T any](name string, value T) (err error) { // {{{
row := service.Db.Conn.QueryRow(`SELECT id, datatype FROM datapoint WHERE name=$1`, name)
type dpRequest = struct {
ID int
value any
}
row := service.Db.Conn.QueryRow(`SELECT id, datatype FROM datapoint WHERE name=$1`, name)
var dpID int
var dpType DatapointType
@ -140,13 +144,21 @@ func DatapointAdd[T any](name string, value T) (err error) { // {{{
case STRING:
_, err = service.Db.Conn.Exec(`INSERT INTO datapoint_value(datapoint_id, value_string) VALUES($1, $2)`, dpID, value)
case DATETIME:
_, err = service.Db.Conn.Exec(`INSERT INTO datapoint_value(datapoint_id, value_datetime) VALUES($1, $2)`, dpID, value)
// Time value is required to be a RFC 3339 formatted time string
var t time.Time
valueStr, ok := any(value).([]byte)
if !ok {
return werr.New("DATETIME value not a string").WithData(dpRequest{dpID, value})
}
t, err = stringToTime(string(valueStr))
if err != nil {
return werr.Wrap(err).WithData(dpRequest{dpID, value}).Log()
}
_, err = service.Db.Conn.Exec(`INSERT INTO datapoint_value(datapoint_id, value_datetime) VALUES($1, $2)`, dpID, t)
}
if err != nil {
err = werr.Wrap(err).WithData(struct {
ID int
value any
}{dpID, value})
err = werr.Wrap(err).WithData(dpRequest{dpID, value})
return
}
@ -327,8 +339,34 @@ func DatapointDelete(id int) (err error) { // {{{
}
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)
func DatapointValues(id int, from, to time.Time) (values []DatapointValue, err error) { // {{{
_, err = service.Db.Conn.Exec(`SELECT set_config('timezone', $1, false)`, smonConfig.Timezone().String())
if err != nil {
err = werr.Wrap(err).WithData(smonConfig.Timezone().String())
return
}
rows, err := service.Db.Conn.Queryx(
`
SELECT
id,
datapoint_id,
ts,
value_int,
value_string,
value_datetime
FROM datapoint_value
WHERE
datapoint_id=$1 AND
ts >= $2 AND
ts <= $3
ORDER BY
ts DESC
`,
id,
from,
to,
)
if err != nil {
err = werr.Wrap(err).WithData(id)
return

29
helper.go Normal file
View File

@ -0,0 +1,29 @@
package main
import (
// External
werr "git.gibonuddevalla.se/go/wrappederror"
// Standard
"time"
)
func stringToTime(strTime string) (t time.Time, err error) {// {{{
t, err = time.Parse(time.RFC3339, strTime)
return
}// }}}
func parseHTMLDateTime(str string, dflt time.Time) (t time.Time, err error) {
// Browser sending 2024-06-27T10:43 (16 characters) when seconds is 00.
if len(str) == 16 {
str += ":00"
}
if str == "" {
return dflt, nil
} else {
t, err = time.ParseInLocation("2006-01-02T15:04:05", str, smonConfig.Timezone())
if err != nil {
err = werr.Wrap(err)
}
}
return
}

144
main.go
View File

@ -19,6 +19,7 @@ import (
"io/fs"
"log/slog"
"net/http"
"net/url"
"os"
"path"
"slices"
@ -27,7 +28,7 @@ import (
"time"
)
const VERSION = "v21"
const VERSION = "v22"
var (
logger *slog.Logger
@ -39,7 +40,7 @@ var (
parsedTemplates map[string]*template.Template
componentFilenames []string
notificationManager notification.Manager
smonConf SmonConfiguration
fileConf FileConfiguration
//go:embed sql
sqlFS embed.FS
@ -91,13 +92,13 @@ func main() { // {{{
}
j, _ := json.Marshal(service.Config.Application)
json.Unmarshal(j, &smonConf)
logFile, err = os.OpenFile(smonConf.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
json.Unmarshal(j, &fileConf)
logFile, err = os.OpenFile(fileConf.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
if err != nil {
logger.Error("application", "error", err)
return
}
if smonConf.NodataInterval < 10 {
if fileConf.NodataInterval < 10 {
logger.Error("application → nodata_interval has to be larger or equal to 10.")
return
}
@ -137,6 +138,7 @@ func main() { // {{{
service.Register("/datapoint/update/{id}", false, false, actionDatapointUpdate)
service.Register("/datapoint/delete/{id}", false, false, actionDatapointDelete)
service.Register("/datapoint/values/{id}", false, false, pageDatapointValues)
service.Register("/datapoint/json/{id}", false, false, actionDatapointJson)
service.Register("/triggers", false, false, pageTriggers)
service.Register("/trigger/create/{sectionID}/{name}", false, false, actionTriggerCreate)
@ -149,11 +151,18 @@ func main() { // {{{
service.Register("/configuration", false, false, pageConfiguration)
service.Register("/configuration/theme", false, false, actionConfigurationTheme)
service.Register("/configuration/timezone", false, false, actionConfigurationTimezone)
service.Register("/entry/{datapoint}", false, false, actionEntryDatapoint)
go nodataLoop()
err = SmonConfigInit()
smonConfig, err = SmonConfigInit()
if err != nil {
logger.Error("configuration", "error", werr.Wrap(err))
os.Exit(1)
}
err = smonConfig.Validate()
if err != nil {
logger.Error("configuration", "error", werr.Wrap(err))
os.Exit(1)
@ -193,6 +202,20 @@ func httpError(w http.ResponseWriter, err error) { // {{{
j, _ := json.Marshal(resp)
w.Write(j)
} // }}}
func pageError(w http.ResponseWriter, redirectURL string, pageErr error) { // {{{
u, err := url.Parse(redirectURL)
if err != nil {
httpError(w, err)
return
}
values := u.Query()
values.Add("_err", pageErr.Error())
u.RawQuery = values.Encode()
w.Header().Add("Location", u.String())
w.WriteHeader(302)
} // }}}
func staticHandler(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
if flagDev && !reloadTemplates(w) {
@ -347,7 +370,7 @@ func getPage(layout, page string) (tmpl *template.Template, err error) { // {{{
funcMap := template.FuncMap{
"format_time": func(t time.Time) template.HTML {
return template.HTML(
t.Local().Format(`<span class="date">2006-01-02</span> <span class="time">15:04<span class="seconds">:05</span></span>`),
t.In(smonConfig.Timezone()).Format(`<span class="date">2006-01-02</span> <span class="time">15:04:05<span class="seconds">:05</span></span>`),
)
},
}
@ -369,13 +392,13 @@ func getPage(layout, page string) (tmpl *template.Template, err error) { // {{{
return
} // }}}
func pageIndex(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
func pageIndex(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
page := Page{
LAYOUT: "main",
PAGE: "index",
CONFIG: smonConfig.Settings,
}
page.Render(w)
page.Render(w, r)
} // }}}
func actionAreaNew(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
@ -485,7 +508,7 @@ func actionSectionDelete(w http.ResponseWriter, r *http.Request, _ *session.T) {
return
} // }}}
func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
func pageProblems(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
page := Page{
LAYOUT: "main",
PAGE: "problems",
@ -518,7 +541,7 @@ func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
"Problems": problems,
"ProblemsGrouped": problemsGrouped,
}
page.Render(w)
page.Render(w, r)
return
} // }}}
func actionProblemAcknowledge(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
@ -583,7 +606,7 @@ func pageDatapoints(w http.ResponseWriter, r *http.Request, _ *session.T) { // {
page.Data = map[string]any{
"Datapoints": datapoints,
}
page.Render(w)
page.Render(w, r)
return
} // }}}
func pageDatapointEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
@ -618,7 +641,7 @@ func pageDatapointEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { /
page.Data = map[string]any{
"Datapoint": datapoint,
}
page.Render(w)
page.Render(w, r)
return
} // }}}
func actionDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
@ -680,8 +703,37 @@ func pageDatapointValues(w http.ResponseWriter, r *http.Request, _ *session.T) {
return
}
// GET parameters.
display := r.URL.Query().Get("display")
if display == "" && datapoint.Datatype == INT {
display = "graph"
}
var timeFrom, timeTo time.Time
yesterday := time.Now().Add(time.Duration(-24 * time.Hour))
timeFrom, err = parseHTMLDateTime(r.URL.Query().Get("f"), yesterday)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
timeTo, err = parseHTMLDateTime(r.URL.Query().Get("t"), time.Now())
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
// Apply an optionally set offset (in seconds).
var offsetTime int
offsetTimeStr := r.URL.Query().Get("offset-time")
offsetTime, err = strconv.Atoi(offsetTimeStr)
timeFrom = timeFrom.Add(time.Second * time.Duration(offsetTime))
timeTo = timeTo.Add(time.Second * time.Duration(offsetTime))
// Fetch data point values according to the times.
var values []DatapointValue
values, err = DatapointValues(id)
values, err = DatapointValues(id, timeFrom, timeTo)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
@ -699,12 +751,46 @@ func pageDatapointValues(w http.ResponseWriter, r *http.Request, _ *session.T) {
page.Data = map[string]any{
"Datapoint": datapoint,
"Values": values,
"TimeFrom": timeFrom.Format("2006-01-02T15:04:05"),
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
"Display": display,
}
page.Render(w)
page.Render(w, r)
return
} // }}}
func actionDatapointJson(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
}
func pageTriggers(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
fromStr := r.URL.Query().Get("f")
from, err := time.ParseInLocation("2006-01-02 15:04:05", fromStr[0:min(19, len(fromStr))], smonConfig.Timezone())
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
toStr := r.URL.Query().Get("t")
to, err := time.ParseInLocation("2006-01-02 15:04:05", toStr[0:min(19, len(toStr))], smonConfig.Timezone())
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
values, err := DatapointValues(id, from, to)
if err != nil {
httpError(w, werr.Wrap(err).Log())
return
}
j, _ := json.Marshal(values)
w.Write(j)
} // }}}
func pageTriggers(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
areas, err := TriggersRetrieve()
if err != nil {
httpError(w, werr.Wrap(err).Log())
@ -724,7 +810,7 @@ func pageTriggers(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
},
}
page.Render(w)
page.Render(w, r)
} // }}}
func actionTriggerCreate(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
name := r.PathValue("name")
@ -812,7 +898,7 @@ func pageTriggerEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { //
},
}
page.Render(w)
page.Render(w, r)
} // }}}
func actionTriggerDatapointAdd(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
triggerID := r.PathValue("id")
@ -934,7 +1020,7 @@ func actionTriggerDelete(w http.ResponseWriter, r *http.Request, _ *session.T) {
w.WriteHeader(302)
} // }}}
func pageConfiguration(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
func pageConfiguration(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
areas, err := AreaRetrieve()
if err != nil {
httpError(w, werr.Wrap(err).Log())
@ -954,7 +1040,7 @@ func pageConfiguration(w http.ResponseWriter, _ *http.Request, _ *session.T) { /
},
}
page.Render(w)
page.Render(w, r)
} // }}}
func actionConfigurationTheme(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
theme := r.FormValue("theme")
@ -967,3 +1053,21 @@ func actionConfigurationTheme(w http.ResponseWriter, r *http.Request, _ *session
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}
func actionConfigurationTimezone(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
timezone := r.FormValue("timezone")
_, err := time.LoadLocation(timezone)
if err != nil {
pageError(w, "/configuration", werr.Wrap(err).Log())
return
}
err = smonConfig.SetTimezone(timezone)
if err != nil {
pageError(w, "/configuration", werr.Wrap(err).Log())
return
}
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
} // }}}

View File

@ -24,8 +24,7 @@ func nodataLoop() {
var datapoints []Datapoint
var err error
// TODO - should be configurable
ticker := time.NewTicker(time.Second * time.Duration(smonConf.NodataInterval))
ticker := time.NewTicker(time.Second * time.Duration(fileConf.NodataInterval))
for {
<-ticker.C
datapoints, err = nodataDatapoints()

View File

@ -22,7 +22,7 @@ type Page struct {
Data any
}
func (p *Page) Render(w http.ResponseWriter) {
func (p *Page) Render(w http.ResponseWriter, r *http.Request) {
tmpl, err := getPage(p.LAYOUT, p.PAGE)
if err != nil {
httpError(w, we.Wrap(err).Log())
@ -49,6 +49,7 @@ func (p *Page) Render(w http.ResponseWriter) {
"PAGE": p.PAGE,
"MENU": p.MENU,
"CONFIG": smonConfig.Settings,
"ERROR": r.URL.Query().Get("_err"),
"Label": p.Label,
"Icon": p.Icon,

1
sql/00020.sql Normal file
View File

@ -0,0 +1 @@
INSERT INTO public.configuration(setting, value) VALUES('TIMEZONE', 'Europe/Stockholm');

1
sql/00021.sql Normal file
View File

@ -0,0 +1 @@
CREATE INDEX datapoint_value_ts_idx ON public.datapoint_value (ts);

View File

@ -41,6 +41,16 @@
grid-template-columns: repeat(2, min-content);
gap: 16px;
white-space: nowrap;
background-color: #2979b8;
padding: 16px 24px;
width: min-content;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
margin-top: 32px;
}
#values .header {
color: #000;
font-weight: bold;
}
.widgets {
display: grid;
@ -67,3 +77,30 @@
grid-template-columns: min-content min-content;
grid-gap: 8px;
}
.value-selector {
display: grid;
grid-template-columns: repeat(2, min-content);
grid-gap: 4px 16px;
margin-top: 16px;
}
.value-selector button {
width: 100px;
align-self: end;
justify-self: end;
}
.graph {
width: 99%;
border: 1px solid #aaa;
margin-top: 16px;
}
.graph #graph-values {
height: calc(100vh - 308px);
}
.time-offset {
display: grid;
grid-template-columns: repeat(3, min-content);
gap: 6px 12px;
align-items: center;
justify-items: center;
margin-top: 8px;
}

View File

@ -42,7 +42,8 @@ button:focus {
}
#datapoints,
#problems-list,
#acknowledged-list {
#acknowledged-list,
#values {
background-color: #fff !important;
border: 1px solid #ddd;
box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25);

View File

@ -12,6 +12,30 @@ html {
[onClick] {
cursor: pointer;
}
#page-error {
display: none;
position: fixed;
z-index: 8192;
width: 500px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 48px;
border: 2px solid #a00;
font-weight: bold;
background: #fff;
box-shadow: 10px 10px 15px 0px rgba(0, 0, 0, 0.25);
}
#page-error.show {
display: block;
position: fixed;
}
#page-error .close {
position: absolute;
top: 16px;
right: 16px;
font-size: 1.5em;
}
#layout {
display: grid;
grid-template-areas: "menu content";
@ -35,9 +59,7 @@ html {
#menu .entry > a {
display: grid;
justify-items: center;
grid-template-rows: 38px
16px
;
grid-template-rows: 38px 16px;
padding: 16px;
color: #7bb8eb;
text-decoration: none;

View File

@ -41,6 +41,16 @@
grid-template-columns: repeat(2, min-content);
gap: 16px;
white-space: nowrap;
background-color: #333;
padding: 16px 24px;
width: min-content;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
margin-top: 32px;
}
#values .header {
color: #f7edd7;
font-weight: bold;
}
.widgets {
display: grid;
@ -67,3 +77,30 @@
grid-template-columns: min-content min-content;
grid-gap: 8px;
}
.value-selector {
display: grid;
grid-template-columns: repeat(2, min-content);
grid-gap: 4px 16px;
margin-top: 16px;
}
.value-selector button {
width: 100px;
align-self: end;
justify-self: end;
}
.graph {
width: 99%;
border: 1px solid #aaa;
margin-top: 16px;
}
.graph #graph-values {
height: calc(100vh - 308px);
}
.time-offset {
display: grid;
grid-template-columns: repeat(3, min-content);
gap: 6px 12px;
align-items: center;
justify-items: center;
margin-top: 8px;
}

View File

@ -42,7 +42,8 @@ button:focus {
}
#datapoints,
#problems-list,
#acknowledged-list {
#acknowledged-list,
#values {
background-color: #fff !important;
border: 1px solid #ddd;
box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25);

View File

@ -12,6 +12,30 @@ html {
[onClick] {
cursor: pointer;
}
#page-error {
display: none;
position: fixed;
z-index: 8192;
width: 500px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 48px;
border: 2px solid #a00;
font-weight: bold;
background: #fff;
box-shadow: 10px 10px 15px 0px rgba(0, 0, 0, 0.25);
}
#page-error.show {
display: block;
position: fixed;
}
#page-error .close {
position: absolute;
top: 16px;
right: 16px;
font-size: 1.5em;
}
#layout {
display: grid;
grid-template-areas: "menu content";
@ -35,9 +59,7 @@ html {
#menu .entry > a {
display: grid;
justify-items: center;
grid-template-rows: 38px
16px
;
grid-template-rows: 38px 16px;
padding: 16px;
color: #777;
text-decoration: none;

View File

@ -0,0 +1,73 @@
function offsetTime(seconds) {
const el = document.getElementById('offset-time')
el.value = seconds
el.form.submit()
}
class Graph {
constructor(datapointID, initialData) {
this.dataset = new Dataset(datapointID, initialData)
this.createGraph()
}
async createGraph() {
this.graphValues = document.getElementById('graph-values');
const values = [{
x: this.dataset.xValues(),
y: this.dataset.yValues(),
}]
this.layout = {
margin: {
t: 24,
r: 0,
},
}
Plotly.react(this.graphValues, values, this.layout);
this.graphValues.on('plotly_relayout', attr => this.relayoutHandler(attr))
}
async relayoutHandler(attr) {
if (!attr.hasOwnProperty('xaxis.range[0]') || !attr.hasOwnProperty('xaxis.range[1]'))
return
this.dataset.extend(attr['xaxis.range[0]'], attr['xaxis.range[1]'])
.then(() => {
const values = [{
x: this.dataset.xValues(),
y: this.dataset.yValues(),
}]
Plotly.react(this.graphValues, values, this.layout)
})
}
}
class Dataset {
constructor(id, initialData) {
this.datapointID = id
this.values = {}
initialData.forEach(v=>this.values[v.ID] = v)
}
xValues() {
return Object.keys(this.values).map(dpID => this.values[dpID].Ts)
}
yValues() {
return Object.keys(this.values).map(dpID => this.values[dpID].ValueInt.Int64)
}
async extend(from, to) {
return fetch(`/datapoint/json/${this.datapointID}?f=${from}&t=${to}`)
.then(data => data.json())
.then(datapointValues => {
datapointValues.forEach(dp=>{
this.values[dp.ID] = dp
})
document.getElementById('num-values').innerText = Object.keys(this.values).length
})
}
}

8
static/js/lib/plotly-2.32.0.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@ -51,6 +51,17 @@
grid-template-columns: repeat(2, min-content);
gap: 16px;
white-space: nowrap;
background-color: @bg3;
padding: 16px 24px;
width: min-content;
border-top-left-radius: 8px;
border-top-right-radius: 8px;
margin-top: 32px;
.header {
color: @text2;
font-weight: bold;
}
}
.widgets {
@ -81,3 +92,36 @@
grid-gap: 8px;
}
}
.value-selector {
display: grid;
grid-template-columns: repeat(2, min-content);
grid-gap: 4px 16px;
margin-top: 16px;
button {
width: 100px;
align-self: end;
justify-self: end;
}
}
.graph {
width: 99%;
border: 1px solid #aaa;
margin-top: 16px;
#graph-values {
height: calc(100vh - 308px);
}
}
.time-offset {
display: grid;
grid-template-columns: repeat(3, min-content);
gap: 6px 12px;
align-items: center;
justify-items: center;
margin-top: 8px;
}

View File

@ -58,7 +58,7 @@ button {
}
}
#datapoints, #problems-list, #acknowledged-list {
#datapoints, #problems-list, #acknowledged-list, #values {
background-color: #fff !important;
border: 1px solid #ddd;
box-shadow: 5px 5px 8px 0px rgba(0,0,0,0.25);

View File

@ -18,6 +18,37 @@ html {
cursor: pointer;
}
#page-error {
display: none;
position: fixed;
z-index: 8192;
width: 500px;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
padding: 48px;
border: 2px solid #a00;
font-weight: bold;
background: #fff;
box-shadow: 10px 10px 15px 0px rgba(0, 0, 0, 0.25);
&.show {
display: block;
position: fixed;
}
.close {
position: absolute;
top: 16px;
right: 16px;
font-size: 1.5em;
}
}
#layout {
display: grid;
grid-template-areas: "menu content";
@ -37,16 +68,17 @@ html {
.entry {
&.selected {
background: @bg3;
a { color: @text2 !important; }
a {
color: @text2 !important;
}
}
&>a {
display: grid;
justify-items: center;
grid-template-rows:
38px
16px
;
38px 16px;
padding: 16px;
color: @text3;
text-decoration: none;
@ -158,6 +190,7 @@ body {
h1,
h2 {
margin-bottom: 4px;
&:first-child {
margin-top: 0px;
}
@ -247,4 +280,3 @@ label {
width: min-content;
border-radius: 8px;
}

View File

@ -101,6 +101,11 @@ func TriggersRetrieveByDatapoint(datapointName string) (triggers []Trigger, err
return
}
// no triggers found for this datapoint.
if data == nil {
return
}
err = json.Unmarshal(data, &triggers)
if err != nil {
err = werr.Wrap(err).WithData(datapointName)

View File

@ -20,6 +20,10 @@
</script>
</head>
<body>
<div id="page-error" class="{{ if ne .ERROR "" }}show{{ end }}">
<div class="close" onclick="console.log(this.parentElement.classList.remove('show'))">✖</div>
{{ .ERROR }}
</div>
<div id="layout">
{{ block "menu" . }}{{ end }}
<div id="page">

View File

@ -95,11 +95,17 @@
</div>
<h1>Theme</h1>
<form action="/configuration/theme" id="theme-set">
<form action="/configuration/theme">
<select name="theme" onchange="console.log(this.form.submit())">
<option value="default_light" {{ if eq "default_light" .CONFIG.THEME }}selected{{ end }}>Default light</option>
<option value="gruvbox" {{ if eq "gruvbox" .CONFIG.THEME }}selected{{ end }}>Gruvbox</option>
</select>
</form>
<h1>Timezone</h1>
<form action="/configuration/timezone" method="post">
<input name="timezone" type="text" value="{{ .CONFIG.TIMEZONE }}">
<button style="margin-left: 8px;">Update</button>
</form>
{{ end }}

View File

@ -1,13 +1,72 @@
{{ define "page" }}
{{ $version := .VERSION }}
{{ $graph := and (eq .Data.Display "graph") (eq .Data.Datapoint.Datatype "INT") }}
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/{{ .CONFIG.THEME }}/datapoints.css">
<script src="/js/{{ .VERSION }}/datapoint_values.js"></script>
<script src="/js/{{ .VERSION }}/lib/plotly-2.32.0.min.js" charset="utf-8"></script>
{{ block "page_label" . }}{{end}}
<form action="/datapoint/values/{{ .Data.Datapoint.ID }}" method="get" style="margin-top: -16px">
<input type="hidden" name="offset-time" id="offset-time" value=0>
{{ if eq .Data.Datapoint.Datatype "INT" }}
<div>
<input name="display" value="graph" type="radio" id="display-graph" {{ if $graph }} checked {{ end}}> <label for="display-graph">Graph</label>
<input name="display" value="list" type="radio" id="display-list" {{ if not $graph }} checked {{ end }}> <label for="display-list">List</label>
</div>
{{ end }}
<div class="value-selector">
<div>Values from</div>
<div>Values to</div>
<input name="f" type="datetime-local" step="1" value="{{ .Data.TimeFrom }}">
<input name="t" type="datetime-local" step="1" value="{{ .Data.TimeTo }}">
<div class="time-offset">
<div><a href="#" onclick="offsetTime(-3600)">◀</a></div>
<div>Hour</div>
<div><a href="#" onclick="offsetTime(3600)">▶</a></div>
<div><a href="#" onclick="offsetTime(-86400)">◀</a></div>
<div>Day</div>
<div><a href="#" onclick="offsetTime(86400)">▶</a></div>
<div><a href="#" onclick="offsetTime(-604800)">◀</a></div>
<div>Week</div>
<div><a href="#" onclick="offsetTime(604800)">▶</a></div>
</div>
<button>OK</button>
</div>
</form>
{{ if $graph }}
<div class="graph">
<div id="graph-values"></div>
</div>
<div style="margin-top: 8px;">
<b>Number of values:</b>
<span id="num-values">{{ len .Data.Values }}</span>
</div>
<script type="text/javascript">
new Graph(
{{ .Data.Datapoint.ID }},
{{ .Data.Values }},
)
</script>
{{ else }}
<div id="values">
<div class="header">Value added</div>
<div class="header">Value</div>
<div class="line"></div>
{{ range .Data.Values }}
<div class="value">{{ format_time .Ts }}</div>
<div class="value">{{ .Value }}</div>
{{ end }}
</div>
{{ end }}
{{ end }}