Compare commits

..

No commits in common. "ff402b8fcb1a9a757f4362c2297075142cb75edb" and "047f39cfa70979802950b699ef46ccdcc1e43f47" have entirely different histories.

24 changed files with 50 additions and 611 deletions

View File

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

View File

@ -6,18 +6,16 @@ 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() (cfg Configuration, err error) { func SmonConfigInit() (err error) {
cfg.Settings = make(map[string]string, 8) smonConfig.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`)
@ -35,49 +33,14 @@ func SmonConfigInit() (cfg Configuration, err error) {
return return
} }
cfg.Settings[setting] = value smonConfig.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
}

View File

@ -120,12 +120,8 @@ 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) { // {{{
type dpRequest = struct {
ID int
value any
}
row := service.Db.Conn.QueryRow(`SELECT id, datatype FROM datapoint WHERE name=$1`, name) 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
@ -144,21 +140,13 @@ 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:
// Time value is required to be a RFC 3339 formatted time string _, err = service.Db.Conn.Exec(`INSERT INTO datapoint_value(datapoint_id, value_datetime) VALUES($1, $2)`, dpID, value)
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(dpRequest{dpID, value}) err = werr.Wrap(err).WithData(struct {
ID int
value any
}{dpID, value})
return return
} }
@ -339,34 +327,8 @@ func DatapointDelete(id int) (err error) { // {{{
} }
return return
} // }}} } // }}}
func DatapointValues(id int, from, to time.Time) (values []DatapointValue, err error) { // {{{ func DatapointValues(id int) (values []DatapointValue, err error) { // {{{
_, err = service.Db.Conn.Exec(`SELECT set_config('timezone', $1, false)`, smonConfig.Timezone().String()) 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(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

View File

@ -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
View File

@ -19,7 +19,6 @@ import (
"io/fs" "io/fs"
"log/slog" "log/slog"
"net/http" "net/http"
"net/url"
"os" "os"
"path" "path"
"slices" "slices"
@ -28,7 +27,7 @@ import (
"time" "time"
) )
const VERSION = "v22" const VERSION = "v21"
var ( var (
logger *slog.Logger logger *slog.Logger
@ -40,7 +39,7 @@ var (
parsedTemplates map[string]*template.Template parsedTemplates map[string]*template.Template
componentFilenames []string componentFilenames []string
notificationManager notification.Manager notificationManager notification.Manager
fileConf FileConfiguration smonConf SmonConfiguration
//go:embed sql //go:embed sql
sqlFS embed.FS sqlFS embed.FS
@ -92,13 +91,13 @@ func main() { // {{{
} }
j, _ := json.Marshal(service.Config.Application) j, _ := json.Marshal(service.Config.Application)
json.Unmarshal(j, &fileConf) json.Unmarshal(j, &smonConf)
logFile, err = os.OpenFile(fileConf.LogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) logFile, err = os.OpenFile(smonConf.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 fileConf.NodataInterval < 10 { if smonConf.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
} }
@ -138,7 +137,6 @@ 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)
@ -151,18 +149,11 @@ 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()
smonConfig, err = SmonConfigInit() 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)
@ -202,20 +193,6 @@ 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) {
@ -370,7 +347,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.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 return
} // }}} } // }}}
func pageIndex(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ func pageIndex(w http.ResponseWriter, _ *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, r) page.Render(w)
} // }}} } // }}}
func actionAreaNew(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ 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 return
} // }}} } // }}}
func pageProblems(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ func pageProblems(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
page := Page{ page := Page{
LAYOUT: "main", LAYOUT: "main",
PAGE: "problems", PAGE: "problems",
@ -541,7 +518,7 @@ func pageProblems(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
"Problems": problems, "Problems": problems,
"ProblemsGrouped": problemsGrouped, "ProblemsGrouped": problemsGrouped,
} }
page.Render(w, r) page.Render(w)
return return
} // }}} } // }}}
func actionProblemAcknowledge(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ 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{ page.Data = map[string]any{
"Datapoints": datapoints, "Datapoints": datapoints,
} }
page.Render(w, r) page.Render(w)
return return
} // }}} } // }}}
func pageDatapointEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ 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{ page.Data = map[string]any{
"Datapoint": datapoint, "Datapoint": datapoint,
} }
page.Render(w, r) page.Render(w)
return return
} // }}} } // }}}
func actionDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{ 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 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, timeFrom, timeTo) values, err = DatapointValues(id)
if err != nil { if err != nil {
httpError(w, werr.Wrap(err).Log()) httpError(w, werr.Wrap(err).Log())
return return
@ -751,46 +699,12 @@ 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, r) page.Render(w)
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
}
fromStr := r.URL.Query().Get("f") func pageTriggers(w http.ResponseWriter, _ *http.Request, _ *session.T) { // {{{
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())
@ -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) { // {{{ func actionTriggerCreate(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
name := r.PathValue("name") 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) { // {{{ func actionTriggerDatapointAdd(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
triggerID := r.PathValue("id") triggerID := r.PathValue("id")
@ -1020,7 +934,7 @@ func actionTriggerDelete(w http.ResponseWriter, r *http.Request, _ *session.T) {
w.WriteHeader(302) 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() areas, err := AreaRetrieve()
if err != nil { if err != nil {
httpError(w, werr.Wrap(err).Log()) 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) { // {{{ func actionConfigurationTheme(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
theme := r.FormValue("theme") theme := r.FormValue("theme")
@ -1053,21 +967,3 @@ 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)
} // }}}

View File

@ -24,7 +24,8 @@ func nodataLoop() {
var datapoints []Datapoint var datapoints []Datapoint
var err error 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 { for {
<-ticker.C <-ticker.C
datapoints, err = nodataDatapoints() datapoints, err = nodataDatapoints()

View File

@ -22,7 +22,7 @@ type Page struct {
Data any 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) 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,7 +49,6 @@ func (p *Page) Render(w http.ResponseWriter, r *http.Request) {
"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,

View File

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

View File

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

View File

@ -41,16 +41,6 @@
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;
@ -77,30 +67,3 @@
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;
}

View File

@ -42,8 +42,7 @@ 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);

View File

@ -12,30 +12,6 @@ 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";
@ -59,7 +35,9 @@ html {
#menu .entry > a { #menu .entry > a {
display: grid; display: grid;
justify-items: center; justify-items: center;
grid-template-rows: 38px 16px; grid-template-rows: 38px
16px
;
padding: 16px; padding: 16px;
color: #7bb8eb; color: #7bb8eb;
text-decoration: none; text-decoration: none;

View File

@ -41,16 +41,6 @@
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;
@ -77,30 +67,3 @@
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;
}

View File

@ -42,8 +42,7 @@ 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);

View File

@ -12,30 +12,6 @@ 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";
@ -59,7 +35,9 @@ html {
#menu .entry > a { #menu .entry > a {
display: grid; display: grid;
justify-items: center; justify-items: center;
grid-template-rows: 38px 16px; grid-template-rows: 38px
16px
;
padding: 16px; padding: 16px;
color: #777; color: #777;
text-decoration: none; text-decoration: none;

View File

@ -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
})
}
}

File diff suppressed because one or more lines are too long

View File

@ -51,17 +51,6 @@
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 {
@ -92,36 +81,3 @@
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;
}

View File

@ -58,7 +58,7 @@ button {
} }
} }
#datapoints, #problems-list, #acknowledged-list, #values { #datapoints, #problems-list, #acknowledged-list {
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);

View File

@ -18,37 +18,6 @@ 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";
@ -68,17 +37,16 @@ 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 16px; 38px
16px
;
padding: 16px; padding: 16px;
color: @text3; color: @text3;
text-decoration: none; text-decoration: none;
@ -190,7 +158,6 @@ body {
h1, h1,
h2 { h2 {
margin-bottom: 4px; margin-bottom: 4px;
&:first-child { &:first-child {
margin-top: 0px; margin-top: 0px;
} }
@ -280,3 +247,4 @@ label {
width: min-content; width: min-content;
border-radius: 8px; border-radius: 8px;
} }

View File

@ -101,11 +101,6 @@ 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)

View File

@ -20,10 +20,6 @@
</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">

View File

@ -95,17 +95,11 @@
</div> </div>
<h1>Theme</h1> <h1>Theme</h1>
<form action="/configuration/theme"> <form action="/configuration/theme" id="theme-set">
<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 }}

View File

@ -1,72 +1,13 @@
{{ 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 }}