Compare commits
No commits in common. "ff402b8fcb1a9a757f4362c2297075142cb75edb" and "047f39cfa70979802950b699ef46ccdcc1e43f47" have entirely different histories.
ff402b8fcb
...
047f39cfa7
@ -1,6 +1,6 @@
|
||||
package main
|
||||
|
||||
type FileConfiguration struct {
|
||||
type SmonConfiguration struct {
|
||||
LogFile string
|
||||
NodataInterval int `json:"nodata_interval"` // in seconds
|
||||
}
|
||||
|
@ -6,18 +6,16 @@ import (
|
||||
|
||||
// Standard
|
||||
"database/sql"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Configuration struct {
|
||||
timezoneLocation *time.Location
|
||||
Settings map[string]string
|
||||
}
|
||||
|
||||
var smonConfig Configuration
|
||||
|
||||
func SmonConfigInit() (cfg Configuration, err error) {
|
||||
cfg.Settings = make(map[string]string, 8)
|
||||
func SmonConfigInit() (err error) {
|
||||
smonConfig.Settings = make(map[string]string, 8)
|
||||
|
||||
var rows *sql.Rows
|
||||
rows, err = service.Db.Conn.Query(`SELECT * FROM public.configuration`)
|
||||
@ -35,49 +33,14 @@ func SmonConfigInit() (cfg Configuration, err error) {
|
||||
return
|
||||
}
|
||||
|
||||
cfg.Settings[setting] = value
|
||||
smonConfig.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
|
||||
}
|
||||
|
54
datapoint.go
54
datapoint.go
@ -120,12 +120,8 @@ func (dp Datapoint) Update() (err error) { // {{{
|
||||
} // }}}
|
||||
|
||||
func DatapointAdd[T any](name string, value T) (err error) { // {{{
|
||||
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
|
||||
|
||||
@ -144,21 +140,13 @@ 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:
|
||||
// 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)
|
||||
_, err = service.Db.Conn.Exec(`INSERT INTO datapoint_value(datapoint_id, value_datetime) VALUES($1, $2)`, dpID, value)
|
||||
}
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithData(dpRequest{dpID, value})
|
||||
err = werr.Wrap(err).WithData(struct {
|
||||
ID int
|
||||
value any
|
||||
}{dpID, value})
|
||||
return
|
||||
}
|
||||
|
||||
@ -339,34 +327,8 @@ func DatapointDelete(id int) (err error) { // {{{
|
||||
}
|
||||
return
|
||||
} // }}}
|
||||
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,
|
||||
)
|
||||
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
|
||||
|
29
helper.go
29
helper.go
@ -1,29 +0,0 @@
|
||||
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,7 +19,6 @@ import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path"
|
||||
"slices"
|
||||
@ -28,7 +27,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const VERSION = "v22"
|
||||
const VERSION = "v21"
|
||||
|
||||
var (
|
||||
logger *slog.Logger
|
||||
@ -40,7 +39,7 @@ var (
|
||||
parsedTemplates map[string]*template.Template
|
||||
componentFilenames []string
|
||||
notificationManager notification.Manager
|
||||
fileConf FileConfiguration
|
||||
smonConf SmonConfiguration
|
||||
|
||||
//go:embed sql
|
||||
sqlFS embed.FS
|
||||
@ -92,13 +91,13 @@ func main() { // {{{
|
||||
}
|
||||
|
||||
j, _ := json.Marshal(service.Config.Application)
|
||||
json.Unmarshal(j, &fileConf)
|
||||
logFile, err = os.OpenFile(fileConf.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
json.Unmarshal(j, &smonConf)
|
||||
logFile, err = os.OpenFile(smonConf.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600)
|
||||
if err != nil {
|
||||
logger.Error("application", "error", err)
|
||||
return
|
||||
}
|
||||
if fileConf.NodataInterval < 10 {
|
||||
if smonConf.NodataInterval < 10 {
|
||||
logger.Error("application → nodata_interval has to be larger or equal to 10.")
|
||||
return
|
||||
}
|
||||
@ -138,7 +137,6 @@ 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)
|
||||
@ -151,18 +149,11 @@ 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()
|
||||
|
||||
smonConfig, err = SmonConfigInit()
|
||||
if err != nil {
|
||||
logger.Error("configuration", "error", werr.Wrap(err))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
err = smonConfig.Validate()
|
||||
err = SmonConfigInit()
|
||||
if err != nil {
|
||||
logger.Error("configuration", "error", werr.Wrap(err))
|
||||
os.Exit(1)
|
||||
@ -202,20 +193,6 @@ 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) {
|
||||
@ -370,7 +347,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.In(smonConfig.Timezone()).Format(`<span class="date">2006-01-02</span> <span class="time">15:04:05<span class="seconds">:05</span></span>`),
|
||||
t.Local().Format(`<span class="date">2006-01-02</span> <span class="time">15:04<span class="seconds">:05</span></span>`),
|
||||
)
|
||||
},
|
||||
}
|
||||
@ -392,13 +369,13 @@ func getPage(layout, page string) (tmpl *template.Template, err error) { // {{{
|
||||
return
|
||||
} // }}}
|
||||
|
||||
func pageIndex(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
func pageIndex(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
|
||||
page := Page{
|
||||
LAYOUT: "main",
|
||||
PAGE: "index",
|
||||
CONFIG: smonConfig.Settings,
|
||||
}
|
||||
page.Render(w, r)
|
||||
page.Render(w)
|
||||
} // }}}
|
||||
|
||||
func actionAreaNew(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
@ -508,7 +485,7 @@ func actionSectionDelete(w http.ResponseWriter, r *http.Request, _ *session.T) {
|
||||
return
|
||||
} // }}}
|
||||
|
||||
func pageProblems(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
|
||||
page := Page{
|
||||
LAYOUT: "main",
|
||||
PAGE: "problems",
|
||||
@ -541,7 +518,7 @@ func pageProblems(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
"Problems": problems,
|
||||
"ProblemsGrouped": problemsGrouped,
|
||||
}
|
||||
page.Render(w, r)
|
||||
page.Render(w)
|
||||
return
|
||||
} // }}}
|
||||
func actionProblemAcknowledge(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
@ -606,7 +583,7 @@ func pageDatapoints(w http.ResponseWriter, r *http.Request, _ *session.T) { // {
|
||||
page.Data = map[string]any{
|
||||
"Datapoints": datapoints,
|
||||
}
|
||||
page.Render(w, r)
|
||||
page.Render(w)
|
||||
return
|
||||
} // }}}
|
||||
func pageDatapointEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
@ -641,7 +618,7 @@ func pageDatapointEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { /
|
||||
page.Data = map[string]any{
|
||||
"Datapoint": datapoint,
|
||||
}
|
||||
page.Render(w, r)
|
||||
page.Render(w)
|
||||
return
|
||||
} // }}}
|
||||
func actionDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
@ -703,37 +680,8 @@ 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, timeFrom, timeTo)
|
||||
values, err = DatapointValues(id)
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).Log())
|
||||
return
|
||||
@ -751,46 +699,12 @@ 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, r)
|
||||
page.Render(w)
|
||||
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
|
||||
}
|
||||
|
||||
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) { // {{{
|
||||
func pageTriggers(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
|
||||
areas, err := TriggersRetrieve()
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).Log())
|
||||
@ -810,7 +724,7 @@ func pageTriggers(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
},
|
||||
}
|
||||
|
||||
page.Render(w, r)
|
||||
page.Render(w)
|
||||
} // }}}
|
||||
func actionTriggerCreate(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
name := r.PathValue("name")
|
||||
@ -898,7 +812,7 @@ func pageTriggerEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { //
|
||||
},
|
||||
}
|
||||
|
||||
page.Render(w, r)
|
||||
page.Render(w)
|
||||
} // }}}
|
||||
func actionTriggerDatapointAdd(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
triggerID := r.PathValue("id")
|
||||
@ -1020,7 +934,7 @@ func actionTriggerDelete(w http.ResponseWriter, r *http.Request, _ *session.T) {
|
||||
w.WriteHeader(302)
|
||||
} // }}}
|
||||
|
||||
func pageConfiguration(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
func pageConfiguration(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
|
||||
areas, err := AreaRetrieve()
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).Log())
|
||||
@ -1040,7 +954,7 @@ func pageConfiguration(w http.ResponseWriter, r *http.Request, _ *session.T) { /
|
||||
},
|
||||
}
|
||||
|
||||
page.Render(w, r)
|
||||
page.Render(w)
|
||||
} // }}}
|
||||
func actionConfigurationTheme(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
theme := r.FormValue("theme")
|
||||
@ -1053,21 +967,3 @@ 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)
|
||||
} // }}}
|
||||
|
@ -24,7 +24,8 @@ func nodataLoop() {
|
||||
var datapoints []Datapoint
|
||||
var err error
|
||||
|
||||
ticker := time.NewTicker(time.Second * time.Duration(fileConf.NodataInterval))
|
||||
// TODO - should be configurable
|
||||
ticker := time.NewTicker(time.Second * time.Duration(smonConf.NodataInterval))
|
||||
for {
|
||||
<-ticker.C
|
||||
datapoints, err = nodataDatapoints()
|
||||
|
3
page.go
3
page.go
@ -22,7 +22,7 @@ type Page struct {
|
||||
Data any
|
||||
}
|
||||
|
||||
func (p *Page) Render(w http.ResponseWriter, r *http.Request) {
|
||||
func (p *Page) Render(w http.ResponseWriter) {
|
||||
tmpl, err := getPage(p.LAYOUT, p.PAGE)
|
||||
if err != nil {
|
||||
httpError(w, we.Wrap(err).Log())
|
||||
@ -49,7 +49,6 @@ func (p *Page) Render(w http.ResponseWriter, r *http.Request) {
|
||||
"PAGE": p.PAGE,
|
||||
"MENU": p.MENU,
|
||||
"CONFIG": smonConfig.Settings,
|
||||
"ERROR": r.URL.Query().Get("_err"),
|
||||
|
||||
"Label": p.Label,
|
||||
"Icon": p.Icon,
|
||||
|
@ -1 +0,0 @@
|
||||
INSERT INTO public.configuration(setting, value) VALUES('TIMEZONE', 'Europe/Stockholm');
|
@ -1 +0,0 @@
|
||||
CREATE INDEX datapoint_value_ts_idx ON public.datapoint_value (ts);
|
@ -41,16 +41,6 @@
|
||||
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;
|
||||
@ -77,30 +67,3 @@
|
||||
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;
|
||||
}
|
||||
|
@ -42,8 +42,7 @@ button:focus {
|
||||
}
|
||||
#datapoints,
|
||||
#problems-list,
|
||||
#acknowledged-list,
|
||||
#values {
|
||||
#acknowledged-list {
|
||||
background-color: #fff !important;
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25);
|
||||
|
@ -12,30 +12,6 @@ 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";
|
||||
@ -59,7 +35,9 @@ 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;
|
||||
|
@ -41,16 +41,6 @@
|
||||
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;
|
||||
@ -77,30 +67,3 @@
|
||||
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;
|
||||
}
|
||||
|
@ -42,8 +42,7 @@ button:focus {
|
||||
}
|
||||
#datapoints,
|
||||
#problems-list,
|
||||
#acknowledged-list,
|
||||
#values {
|
||||
#acknowledged-list {
|
||||
background-color: #fff !important;
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25);
|
||||
|
@ -12,30 +12,6 @@ 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";
|
||||
@ -59,7 +35,9 @@ 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;
|
||||
|
@ -1,73 +0,0 @@
|
||||
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
8
static/js/lib/plotly-2.32.0.min.js
vendored
File diff suppressed because one or more lines are too long
@ -51,17 +51,6 @@
|
||||
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 {
|
||||
@ -92,36 +81,3 @@
|
||||
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, #values {
|
||||
#datapoints, #problems-list, #acknowledged-list {
|
||||
background-color: #fff !important;
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: 5px 5px 8px 0px rgba(0,0,0,0.25);
|
||||
|
@ -18,37 +18,6 @@ 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";
|
||||
@ -68,17 +37,16 @@ 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;
|
||||
@ -190,7 +158,6 @@ body {
|
||||
h1,
|
||||
h2 {
|
||||
margin-bottom: 4px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0px;
|
||||
}
|
||||
@ -280,3 +247,4 @@ label {
|
||||
width: min-content;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
|
@ -101,11 +101,6 @@ 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)
|
||||
|
@ -20,10 +20,6 @@
|
||||
</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">
|
||||
|
@ -95,17 +95,11 @@
|
||||
</div>
|
||||
|
||||
<h1>Theme</h1>
|
||||
<form action="/configuration/theme">
|
||||
<form action="/configuration/theme" id="theme-set">
|
||||
<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 }}
|
||||
|
@ -1,72 +1,13 @@
|
||||
{{ 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 }}
|
||||
|
Loading…
Reference in New Issue
Block a user