Compare commits
15 Commits
047f39cfa7
...
ff402b8fcb
Author | SHA1 | Date | |
---|---|---|---|
|
ff402b8fcb | ||
|
0eb945f3e0 | ||
|
50664ca965 | ||
|
16b4bd53f4 | ||
|
530204c1a5 | ||
|
ab87da256c | ||
|
3109124a88 | ||
|
22f6b6a413 | ||
|
65c0984348 | ||
|
4b21b0ac07 | ||
|
617e025be4 | ||
|
c28c848b95 | ||
|
43d8938459 | ||
|
b6e1139e8a | ||
|
f0a6ce7b95 |
@ -1,6 +1,6 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
type SmonConfiguration struct {
|
type FileConfiguration struct {
|
||||||
LogFile string
|
LogFile string
|
||||||
NodataInterval int `json:"nodata_interval"` // in seconds
|
NodataInterval int `json:"nodata_interval"` // in seconds
|
||||||
}
|
}
|
||||||
|
@ -6,16 +6,18 @@ import (
|
|||||||
|
|
||||||
// Standard
|
// Standard
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Configuration struct {
|
type Configuration struct {
|
||||||
|
timezoneLocation *time.Location
|
||||||
Settings map[string]string
|
Settings map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
var smonConfig Configuration
|
var smonConfig Configuration
|
||||||
|
|
||||||
func SmonConfigInit() (err error) {
|
func SmonConfigInit() (cfg Configuration, err error) {
|
||||||
smonConfig.Settings = make(map[string]string, 8)
|
cfg.Settings = make(map[string]string, 8)
|
||||||
|
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
rows, err = service.Db.Conn.Query(`SELECT * FROM public.configuration`)
|
rows, err = service.Db.Conn.Query(`SELECT * FROM public.configuration`)
|
||||||
@ -33,14 +35,49 @@ func SmonConfigInit() (err error) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
smonConfig.Settings[setting] = value
|
cfg.Settings[setting] = value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = cfg.LoadTimezone()
|
||||||
return
|
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) {
|
func (cfg *Configuration) SetTheme(theme string) (err error) {
|
||||||
cfg.Settings["THEME"] = theme
|
cfg.Settings["THEME"] = theme
|
||||||
_, err = service.Db.Conn.Exec(`UPDATE public.configuration SET value=$1 WHERE setting='THEME'`, theme)
|
_, err = service.Db.Conn.Exec(`UPDATE public.configuration SET value=$1 WHERE setting='THEME'`, theme)
|
||||||
return
|
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
|
||||||
|
}
|
||||||
|
54
datapoint.go
54
datapoint.go
@ -120,8 +120,12 @@ func (dp Datapoint) Update() (err error) { // {{{
|
|||||||
} // }}}
|
} // }}}
|
||||||
|
|
||||||
func DatapointAdd[T any](name string, value T) (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 dpID int
|
||||||
var dpType DatapointType
|
var dpType DatapointType
|
||||||
|
|
||||||
@ -140,13 +144,21 @@ func DatapointAdd[T any](name string, value T) (err error) { // {{{
|
|||||||
case STRING:
|
case STRING:
|
||||||
_, err = service.Db.Conn.Exec(`INSERT INTO datapoint_value(datapoint_id, value_string) VALUES($1, $2)`, dpID, value)
|
_, err = service.Db.Conn.Exec(`INSERT INTO datapoint_value(datapoint_id, value_string) VALUES($1, $2)`, dpID, value)
|
||||||
case DATETIME:
|
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 {
|
if err != nil {
|
||||||
err = werr.Wrap(err).WithData(struct {
|
err = werr.Wrap(err).WithData(dpRequest{dpID, value})
|
||||||
ID int
|
|
||||||
value any
|
|
||||||
}{dpID, value})
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -327,8 +339,34 @@ func DatapointDelete(id int) (err error) { // {{{
|
|||||||
}
|
}
|
||||||
return
|
return
|
||||||
} // }}}
|
} // }}}
|
||||||
func DatapointValues(id int) (values []DatapointValue, err error) { // {{{
|
func DatapointValues(id int, from, to time.Time) (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)
|
_, 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 {
|
if err != nil {
|
||||||
err = werr.Wrap(err).WithData(id)
|
err = werr.Wrap(err).WithData(id)
|
||||||
return
|
return
|
||||||
|
29
helper.go
Normal file
29
helper.go
Normal 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
144
main.go
@ -19,6 +19,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
"slices"
|
"slices"
|
||||||
@ -27,7 +28,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const VERSION = "v21"
|
const VERSION = "v22"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
@ -39,7 +40,7 @@ var (
|
|||||||
parsedTemplates map[string]*template.Template
|
parsedTemplates map[string]*template.Template
|
||||||
componentFilenames []string
|
componentFilenames []string
|
||||||
notificationManager notification.Manager
|
notificationManager notification.Manager
|
||||||
smonConf SmonConfiguration
|
fileConf FileConfiguration
|
||||||
|
|
||||||
//go:embed sql
|
//go:embed sql
|
||||||
sqlFS embed.FS
|
sqlFS embed.FS
|
||||||
@ -91,13 +92,13 @@ func main() { // {{{
|
|||||||
}
|
}
|
||||||
|
|
||||||
j, _ := json.Marshal(service.Config.Application)
|
j, _ := json.Marshal(service.Config.Application)
|
||||||
json.Unmarshal(j, &smonConf)
|
json.Unmarshal(j, &fileConf)
|
||||||
logFile, err = os.OpenFile(smonConf.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
logFile, err = os.OpenFile(fileConf.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("application", "error", err)
|
logger.Error("application", "error", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if smonConf.NodataInterval < 10 {
|
if fileConf.NodataInterval < 10 {
|
||||||
logger.Error("application → nodata_interval has to be larger or equal to 10.")
|
logger.Error("application → nodata_interval has to be larger or equal to 10.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -137,6 +138,7 @@ func main() { // {{{
|
|||||||
service.Register("/datapoint/update/{id}", false, false, actionDatapointUpdate)
|
service.Register("/datapoint/update/{id}", false, false, actionDatapointUpdate)
|
||||||
service.Register("/datapoint/delete/{id}", false, false, actionDatapointDelete)
|
service.Register("/datapoint/delete/{id}", false, false, actionDatapointDelete)
|
||||||
service.Register("/datapoint/values/{id}", false, false, pageDatapointValues)
|
service.Register("/datapoint/values/{id}", false, false, pageDatapointValues)
|
||||||
|
service.Register("/datapoint/json/{id}", false, false, actionDatapointJson)
|
||||||
|
|
||||||
service.Register("/triggers", false, false, pageTriggers)
|
service.Register("/triggers", false, false, pageTriggers)
|
||||||
service.Register("/trigger/create/{sectionID}/{name}", false, false, actionTriggerCreate)
|
service.Register("/trigger/create/{sectionID}/{name}", false, false, actionTriggerCreate)
|
||||||
@ -149,11 +151,18 @@ func main() { // {{{
|
|||||||
|
|
||||||
service.Register("/configuration", false, false, pageConfiguration)
|
service.Register("/configuration", false, false, pageConfiguration)
|
||||||
service.Register("/configuration/theme", false, false, actionConfigurationTheme)
|
service.Register("/configuration/theme", false, false, actionConfigurationTheme)
|
||||||
|
service.Register("/configuration/timezone", false, false, actionConfigurationTimezone)
|
||||||
service.Register("/entry/{datapoint}", false, false, actionEntryDatapoint)
|
service.Register("/entry/{datapoint}", false, false, actionEntryDatapoint)
|
||||||
|
|
||||||
go nodataLoop()
|
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 {
|
if err != nil {
|
||||||
logger.Error("configuration", "error", werr.Wrap(err))
|
logger.Error("configuration", "error", werr.Wrap(err))
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@ -193,6 +202,20 @@ func httpError(w http.ResponseWriter, err error) { // {{{
|
|||||||
j, _ := json.Marshal(resp)
|
j, _ := json.Marshal(resp)
|
||||||
w.Write(j)
|
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) { // {{{
|
func staticHandler(w http.ResponseWriter, r *http.Request, sess *session.T) { // {{{
|
||||||
if flagDev && !reloadTemplates(w) {
|
if flagDev && !reloadTemplates(w) {
|
||||||
@ -347,7 +370,7 @@ func getPage(layout, page string) (tmpl *template.Template, err error) { // {{{
|
|||||||
funcMap := template.FuncMap{
|
funcMap := template.FuncMap{
|
||||||
"format_time": func(t time.Time) template.HTML {
|
"format_time": func(t time.Time) template.HTML {
|
||||||
return 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
|
return
|
||||||
} // }}}
|
} // }}}
|
||||||
|
|
||||||
func pageIndex(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
|
func pageIndex(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||||
page := Page{
|
page := Page{
|
||||||
LAYOUT: "main",
|
LAYOUT: "main",
|
||||||
PAGE: "index",
|
PAGE: "index",
|
||||||
CONFIG: smonConfig.Settings,
|
CONFIG: smonConfig.Settings,
|
||||||
}
|
}
|
||||||
page.Render(w)
|
page.Render(w, r)
|
||||||
} // }}}
|
} // }}}
|
||||||
|
|
||||||
func actionAreaNew(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
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
|
return
|
||||||
} // }}}
|
} // }}}
|
||||||
|
|
||||||
func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
|
func pageProblems(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||||
page := Page{
|
page := Page{
|
||||||
LAYOUT: "main",
|
LAYOUT: "main",
|
||||||
PAGE: "problems",
|
PAGE: "problems",
|
||||||
@ -518,7 +541,7 @@ func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
|
|||||||
"Problems": problems,
|
"Problems": problems,
|
||||||
"ProblemsGrouped": problemsGrouped,
|
"ProblemsGrouped": problemsGrouped,
|
||||||
}
|
}
|
||||||
page.Render(w)
|
page.Render(w, r)
|
||||||
return
|
return
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionProblemAcknowledge(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
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{
|
page.Data = map[string]any{
|
||||||
"Datapoints": datapoints,
|
"Datapoints": datapoints,
|
||||||
}
|
}
|
||||||
page.Render(w)
|
page.Render(w, r)
|
||||||
return
|
return
|
||||||
} // }}}
|
} // }}}
|
||||||
func pageDatapointEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
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{
|
page.Data = map[string]any{
|
||||||
"Datapoint": datapoint,
|
"Datapoint": datapoint,
|
||||||
}
|
}
|
||||||
page.Render(w)
|
page.Render(w, r)
|
||||||
return
|
return
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
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
|
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
|
var values []DatapointValue
|
||||||
values, err = DatapointValues(id)
|
values, err = DatapointValues(id, timeFrom, timeTo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(w, werr.Wrap(err).Log())
|
httpError(w, werr.Wrap(err).Log())
|
||||||
return
|
return
|
||||||
@ -699,12 +751,46 @@ func pageDatapointValues(w http.ResponseWriter, r *http.Request, _ *session.T) {
|
|||||||
page.Data = map[string]any{
|
page.Data = map[string]any{
|
||||||
"Datapoint": datapoint,
|
"Datapoint": datapoint,
|
||||||
"Values": values,
|
"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
|
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()
|
areas, err := TriggersRetrieve()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(w, werr.Wrap(err).Log())
|
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) { // {{{
|
func actionTriggerCreate(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||||
name := r.PathValue("name")
|
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) { // {{{
|
func actionTriggerDatapointAdd(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||||
triggerID := r.PathValue("id")
|
triggerID := r.PathValue("id")
|
||||||
@ -934,7 +1020,7 @@ func actionTriggerDelete(w http.ResponseWriter, r *http.Request, _ *session.T) {
|
|||||||
w.WriteHeader(302)
|
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()
|
areas, err := AreaRetrieve()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(w, werr.Wrap(err).Log())
|
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) { // {{{
|
func actionConfigurationTheme(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||||
theme := r.FormValue("theme")
|
theme := r.FormValue("theme")
|
||||||
@ -967,3 +1053,21 @@ func actionConfigurationTheme(w http.ResponseWriter, r *http.Request, _ *session
|
|||||||
w.Header().Add("Location", "/configuration")
|
w.Header().Add("Location", "/configuration")
|
||||||
w.WriteHeader(302)
|
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)
|
||||||
|
} // }}}
|
||||||
|
@ -24,8 +24,7 @@ func nodataLoop() {
|
|||||||
var datapoints []Datapoint
|
var datapoints []Datapoint
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
// TODO - should be configurable
|
ticker := time.NewTicker(time.Second * time.Duration(fileConf.NodataInterval))
|
||||||
ticker := time.NewTicker(time.Second * time.Duration(smonConf.NodataInterval))
|
|
||||||
for {
|
for {
|
||||||
<-ticker.C
|
<-ticker.C
|
||||||
datapoints, err = nodataDatapoints()
|
datapoints, err = nodataDatapoints()
|
||||||
|
3
page.go
3
page.go
@ -22,7 +22,7 @@ type Page struct {
|
|||||||
Data any
|
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)
|
tmpl, err := getPage(p.LAYOUT, p.PAGE)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
httpError(w, we.Wrap(err).Log())
|
httpError(w, we.Wrap(err).Log())
|
||||||
@ -49,6 +49,7 @@ func (p *Page) Render(w http.ResponseWriter) {
|
|||||||
"PAGE": p.PAGE,
|
"PAGE": p.PAGE,
|
||||||
"MENU": p.MENU,
|
"MENU": p.MENU,
|
||||||
"CONFIG": smonConfig.Settings,
|
"CONFIG": smonConfig.Settings,
|
||||||
|
"ERROR": r.URL.Query().Get("_err"),
|
||||||
|
|
||||||
"Label": p.Label,
|
"Label": p.Label,
|
||||||
"Icon": p.Icon,
|
"Icon": p.Icon,
|
||||||
|
1
sql/00020.sql
Normal file
1
sql/00020.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
INSERT INTO public.configuration(setting, value) VALUES('TIMEZONE', 'Europe/Stockholm');
|
1
sql/00021.sql
Normal file
1
sql/00021.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
CREATE INDEX datapoint_value_ts_idx ON public.datapoint_value (ts);
|
@ -41,6 +41,16 @@
|
|||||||
grid-template-columns: repeat(2, min-content);
|
grid-template-columns: repeat(2, min-content);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
white-space: nowrap;
|
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 {
|
.widgets {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -67,3 +77,30 @@
|
|||||||
grid-template-columns: min-content min-content;
|
grid-template-columns: min-content min-content;
|
||||||
grid-gap: 8px;
|
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;
|
||||||
|
}
|
||||||
|
@ -42,7 +42,8 @@ button:focus {
|
|||||||
}
|
}
|
||||||
#datapoints,
|
#datapoints,
|
||||||
#problems-list,
|
#problems-list,
|
||||||
#acknowledged-list {
|
#acknowledged-list,
|
||||||
|
#values {
|
||||||
background-color: #fff !important;
|
background-color: #fff !important;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25);
|
box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25);
|
||||||
|
@ -12,6 +12,30 @@ html {
|
|||||||
[onClick] {
|
[onClick] {
|
||||||
cursor: pointer;
|
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 {
|
#layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas: "menu content";
|
grid-template-areas: "menu content";
|
||||||
@ -35,9 +59,7 @@ html {
|
|||||||
#menu .entry > a {
|
#menu .entry > a {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
grid-template-rows: 38px
|
grid-template-rows: 38px 16px;
|
||||||
16px
|
|
||||||
;
|
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
color: #7bb8eb;
|
color: #7bb8eb;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
@ -41,6 +41,16 @@
|
|||||||
grid-template-columns: repeat(2, min-content);
|
grid-template-columns: repeat(2, min-content);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
white-space: nowrap;
|
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 {
|
.widgets {
|
||||||
display: grid;
|
display: grid;
|
||||||
@ -67,3 +77,30 @@
|
|||||||
grid-template-columns: min-content min-content;
|
grid-template-columns: min-content min-content;
|
||||||
grid-gap: 8px;
|
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;
|
||||||
|
}
|
||||||
|
@ -42,7 +42,8 @@ button:focus {
|
|||||||
}
|
}
|
||||||
#datapoints,
|
#datapoints,
|
||||||
#problems-list,
|
#problems-list,
|
||||||
#acknowledged-list {
|
#acknowledged-list,
|
||||||
|
#values {
|
||||||
background-color: #fff !important;
|
background-color: #fff !important;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25);
|
box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25);
|
||||||
|
@ -12,6 +12,30 @@ html {
|
|||||||
[onClick] {
|
[onClick] {
|
||||||
cursor: pointer;
|
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 {
|
#layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas: "menu content";
|
grid-template-areas: "menu content";
|
||||||
@ -35,9 +59,7 @@ html {
|
|||||||
#menu .entry > a {
|
#menu .entry > a {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
grid-template-rows: 38px
|
grid-template-rows: 38px 16px;
|
||||||
16px
|
|
||||||
;
|
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
color: #777;
|
color: #777;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
73
static/js/datapoint_values.js
Normal file
73
static/js/datapoint_values.js
Normal 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
8
static/js/lib/plotly-2.32.0.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -51,6 +51,17 @@
|
|||||||
grid-template-columns: repeat(2, min-content);
|
grid-template-columns: repeat(2, min-content);
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
white-space: nowrap;
|
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 {
|
.widgets {
|
||||||
@ -81,3 +92,36 @@
|
|||||||
grid-gap: 8px;
|
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;
|
||||||
|
}
|
||||||
|
@ -58,7 +58,7 @@ button {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#datapoints, #problems-list, #acknowledged-list {
|
#datapoints, #problems-list, #acknowledged-list, #values {
|
||||||
background-color: #fff !important;
|
background-color: #fff !important;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
box-shadow: 5px 5px 8px 0px rgba(0,0,0,0.25);
|
box-shadow: 5px 5px 8px 0px rgba(0,0,0,0.25);
|
||||||
|
@ -18,6 +18,37 @@ html {
|
|||||||
cursor: pointer;
|
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 {
|
#layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-areas: "menu content";
|
grid-template-areas: "menu content";
|
||||||
@ -37,16 +68,17 @@ html {
|
|||||||
.entry {
|
.entry {
|
||||||
&.selected {
|
&.selected {
|
||||||
background: @bg3;
|
background: @bg3;
|
||||||
a { color: @text2 !important; }
|
|
||||||
|
a {
|
||||||
|
color: @text2 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&>a {
|
&>a {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
grid-template-rows:
|
grid-template-rows:
|
||||||
38px
|
38px 16px;
|
||||||
16px
|
|
||||||
;
|
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
color: @text3;
|
color: @text3;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
@ -158,6 +190,7 @@ body {
|
|||||||
h1,
|
h1,
|
||||||
h2 {
|
h2 {
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
|
|
||||||
&:first-child {
|
&:first-child {
|
||||||
margin-top: 0px;
|
margin-top: 0px;
|
||||||
}
|
}
|
||||||
@ -247,4 +280,3 @@ label {
|
|||||||
width: min-content;
|
width: min-content;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -101,6 +101,11 @@ func TriggersRetrieveByDatapoint(datapointName string) (triggers []Trigger, err
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// no triggers found for this datapoint.
|
||||||
|
if data == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(data, &triggers)
|
err = json.Unmarshal(data, &triggers)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = werr.Wrap(err).WithData(datapointName)
|
err = werr.Wrap(err).WithData(datapointName)
|
||||||
|
@ -20,6 +20,10 @@
|
|||||||
</script>
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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">
|
<div id="layout">
|
||||||
{{ block "menu" . }}{{ end }}
|
{{ block "menu" . }}{{ end }}
|
||||||
<div id="page">
|
<div id="page">
|
||||||
|
@ -95,11 +95,17 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h1>Theme</h1>
|
<h1>Theme</h1>
|
||||||
<form action="/configuration/theme" id="theme-set">
|
<form action="/configuration/theme">
|
||||||
<select name="theme" onchange="console.log(this.form.submit())">
|
<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="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>
|
<option value="gruvbox" {{ if eq "gruvbox" .CONFIG.THEME }}selected{{ end }}>Gruvbox</option>
|
||||||
</select>
|
</select>
|
||||||
</form>
|
</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 }}
|
{{ end }}
|
||||||
|
@ -1,13 +1,72 @@
|
|||||||
{{ define "page" }}
|
{{ define "page" }}
|
||||||
{{ $version := .VERSION }}
|
{{ $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">
|
<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}}
|
{{ 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 id="values">
|
||||||
|
<div class="header">Value added</div>
|
||||||
|
<div class="header">Value</div>
|
||||||
|
<div class="line"></div>
|
||||||
{{ range .Data.Values }}
|
{{ range .Data.Values }}
|
||||||
<div class="value">{{ format_time .Ts }}</div>
|
<div class="value">{{ format_time .Ts }}</div>
|
||||||
<div class="value">{{ .Value }}</div>
|
<div class="value">{{ .Value }}</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
</div>
|
</div>
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
Loading…
Reference in New Issue
Block a user