Compare commits
51 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
8fa8bb4c3a | ||
|
7cf2b60803 | ||
|
db41d360dc | ||
|
6685f8bc46 | ||
|
96f7b50e4e | ||
|
8ef6a2bbfa | ||
|
d1599fe2b9 | ||
|
6909b223a7 | ||
|
570ea064aa | ||
|
f8a64e4dfd | ||
|
a8bdeae3a9 | ||
|
1deb80c776 | ||
|
85a6da0b0a | ||
|
17e555e7fc | ||
|
29e1001665 | ||
|
fe48cf780e | ||
|
169c881134 | ||
|
e55e4261dd | ||
|
c3fbfa307f | ||
|
cd123ae1c1 | ||
|
9689283c0e | ||
|
3adf85a0f6 | ||
|
2c5b434fd2 | ||
|
1215b13d47 | ||
|
e10783ec54 | ||
|
06f88f697c | ||
|
414ca0a95c | ||
|
257a4968ec | ||
|
5d24baedac | ||
|
fefd4af10c | ||
|
0de7ca4bef | ||
|
3227c22de1 | ||
|
1bef8719c0 | ||
|
b53b507355 | ||
|
4d7f0d557e | ||
|
09241e73a5 | ||
|
332788dd20 | ||
|
f7dcb4a079 | ||
|
9700bc9d3c | ||
|
4c908f4891 | ||
|
0f69874475 | ||
|
a985f531ea | ||
|
df714c750b | ||
|
aa368c0b0d | ||
865f1ee184 | |||
6108cb7046 | |||
|
89c7c30980 | ||
854be4985f | |||
|
3506566e45 | ||
|
51f96cddb1 | ||
|
df8e3fba23 |
145
README.md
@ -1,10 +1,37 @@
|
||||
---
|
||||
gitea: none
|
||||
include_toc: true
|
||||
---
|
||||
|
||||
# Smon
|
||||
|
||||
Smon (Simple Monitoring) is a small and easy to maintain single user monitoring system. \
|
||||
It is developed for the developer's personal need for monitoring a small number of datapoints and get notifications,
|
||||
without having to maintain a huge system like Zabbix. \
|
||||
It is also possible to troubleshoot with historial data and graphs.
|
||||
|
||||
There is no concept of users or passwords. \
|
||||
If a requirement, authentiation and authorization can be handled by a HTTP reverse proxy.
|
||||
|
||||
All data is sent to the system via an HTTP request to /entry/`datapoint_name`. \
|
||||
Smon can't poll data.
|
||||
|
||||
## Screenshots
|
||||
|
||||
| Problems | Datapoints | Graphs | Triggers |
|
||||
| --- | --- | --- | --- |
|
||||
| <a href="https://git.ahall.se/magnus/smon/raw/branch/main/screenshots/problems.jpg"><img src="https://git.ahall.se/magnus/smon/raw/branch/main/screenshots/problems_small.jpg"></a> | <a href="https://git.ahall.se/magnus/smon/raw/branch/main/screenshots/datapoints.jpg"><img src="https://git.ahall.se/magnus/smon/raw/branch/main/screenshots/datapoints_small.jpg"></a> | <a href="https://git.ahall.se/magnus/smon/raw/branch/main/screenshots/datapoint_values.jpg"><img src="https://git.ahall.se/magnus/smon/raw/branch/main/screenshots/datapoint_values_small.jpg"></a> | <a href="https://git.ahall.se/magnus/smon/raw/branch/main/screenshots/triggers.jpg"><img src="https://git.ahall.se/magnus/smon/raw/branch/main/screenshots/triggers_small.jpg"></a> |
|
||||
|
||||
|
||||
|
||||
|
||||
# Quick start
|
||||
|
||||
1) Create an empty database
|
||||
1) Create the configuration file (default ~/.config/smon.yaml)
|
||||
1) Run ./smon (will create the database schema)
|
||||
1) Create an empty database (smon only supports PostgreSQL).
|
||||
1) Create the configuration file (default ~/.config/smon.yaml).
|
||||
1) Run ./smon (will create the database schema).
|
||||
|
||||
# Configuration
|
||||
## Configuration
|
||||
|
||||
```yaml
|
||||
network:
|
||||
@ -23,25 +50,117 @@ database:
|
||||
username: smon
|
||||
password: you_wish
|
||||
|
||||
session:
|
||||
daysvalid: 31
|
||||
|
||||
application:
|
||||
logfile: /var/log/smon.log
|
||||
nodata_interval: 60
|
||||
```
|
||||
|
||||
# Data from systems to datapoints
|
||||
## Sending data to datapoints
|
||||
|
||||
`curl -d 200 http://localhost:9000/entry/datapoint_name`
|
||||
|
||||
## Datetime format
|
||||
|
||||
Datetime data is to be in the format of `2006-01-02T15:04:05+02:00` or `2006-01-02T15:04:05+0200`.
|
||||
|
||||
Use `date '+%FT%T%z'` to get a value from systems in correct format.
|
||||
|
||||
# Theming
|
||||
|
||||
|
||||
|
||||
# Concepts
|
||||
|
||||
Core concepts in Smon are:
|
||||
* datapoints.
|
||||
* triggers.
|
||||
* problems.
|
||||
* notifications.
|
||||
* areas.
|
||||
|
||||
## Datapoints
|
||||
|
||||
A datapoint has a unique name. Recommended is to use only a-z, A-Z, 0-9, dots, dashes and underscores for easier handling in URLs and trigger conditions.
|
||||
|
||||
There are three data types:
|
||||
* INT.
|
||||
* STRING.
|
||||
* DATETIME.
|
||||
|
||||
INT accepts values like -100, 0, 137 and 29561298561 and is stored in the int8 postgresql type.
|
||||
|
||||
STRING is text data and stored by an unlimited varchar.
|
||||
|
||||
DATETIME is a date with time and timezone in the format of `2006-01-02T15:04:05+02:00` or `2006-01-02T15:04:05+0200`. Stored in a field of timestamptz.
|
||||
|
||||
Recommended is to provide from where (machine, system or script location...) the data is being pushed to Smon in the comment field.
|
||||
|
||||
## Triggers
|
||||
|
||||
Triggers have datapoints the expression is being able to use.
|
||||
|
||||
Expressions are written with the [[https://expr-lang.org/|Expr]] language. See the [[https://expr-lang.org/docs/language-definition|language reference]] for available functions and syntax.
|
||||
|
||||
The trigger is evaluating the expression when data is entered through the /entry/`datapoint_name` URL and a problem is issued when the expression returns `true`.
|
||||
|
||||
### Examples
|
||||
|
||||
The disk usage (reported in percent) for server01 has exceeded 90%:
|
||||
|
||||
`server01_disk_usage_root > 90`
|
||||
|
||||
It was more than 48 hours since Borg finished on server01:
|
||||
|
||||
`(now() - borg_server01_finished).Hours() > 48`
|
||||
|
||||
## Problems
|
||||
|
||||
One or more datapoints that trips a trigger issues a problem.
|
||||
|
||||
Problems are archived in the database and can be looked up historically.
|
||||
|
||||
A page of current problems is available to see the current problem state, either as a list or categorised.
|
||||
|
||||
Problems can be acknowledged if they are known problems that will not be currently fixed.
|
||||
|
||||
## Notifications
|
||||
|
||||
Smon has a couple of notification services (currently [[https://ntfy.sh|NTFY]], [[https://pushover.net|Pushover]] and script).
|
||||
|
||||
Services are added with a prio. The service with the lowest prio is tried first. \
|
||||
If sending through the service fails, the next service is tried and so on until one succeeds.
|
||||
|
||||
What is sent isn't configurable (for now). What is sent is:
|
||||
* PROBLEM (when a problem is created) or OK (when a trigger evaluates to false).
|
||||
* Trigger name
|
||||
|
||||
|
||||
## NTFY
|
||||
|
||||
An URL is provided to the topic which should receive the notifications.
|
||||
|
||||
## Pushover
|
||||
|
||||
The user key and API key for the Pushover application is needed. Additionally, a device key can be specified as well.
|
||||
|
||||
## Script
|
||||
|
||||
The script service is defined with a filename.
|
||||
|
||||
The script is called with three parameters:
|
||||
1) Problem ID
|
||||
1) Acknowledge URL
|
||||
1) Message
|
||||
|
||||
## Areas
|
||||
|
||||
Go to the `Config` icon to add areas. These are available to categorize triggers and problems for easier troubleshooting.
|
||||
|
||||
Each area has sections to further group problems and triggers.
|
||||
|
||||
|
||||
|
||||
|
||||
# Development
|
||||
|
||||
A couple of small notes on development.
|
||||
|
||||
## Theming
|
||||
|
||||
* Add theme to select tag in `/views/pages/configuration.gotmpl`.
|
||||
* Create `/static/less/theme-<theme-name>.less`.
|
||||
|
45
datapoint.go
@ -54,14 +54,14 @@ func (dp DatapointValue) Value() any { // {{{
|
||||
}
|
||||
|
||||
if dp.ValueDateTime.Valid {
|
||||
return dp.ValueDateTime.Time
|
||||
return dp.ValueDateTime.Time.In(smonConfig.timezoneLocation)
|
||||
}
|
||||
|
||||
return nil
|
||||
} // }}}
|
||||
func (dp DatapointValue) FormattedTime() string { // {{{
|
||||
if dp.ValueDateTime.Valid {
|
||||
return dp.ValueDateTime.Time.Format("2006-01-02 15:04:05")
|
||||
return dp.ValueDateTime.Time.In(smonConfig.timezoneLocation).Format("2006-01-02 15:04:05")
|
||||
}
|
||||
return "invalid time"
|
||||
} // }}}
|
||||
@ -162,8 +162,6 @@ func DatapointAdd[T any](name string, value T) (err error) { // {{{
|
||||
return
|
||||
}
|
||||
|
||||
service.Db.Conn.Exec(`UPDATE datapoint SET last_value = NOW(), nodata_is_problem = false WHERE id=$1`, dpID)
|
||||
|
||||
return
|
||||
} // }}}
|
||||
|
||||
@ -172,30 +170,19 @@ func DatapointsRetrieve() (dps []Datapoint, err error) { // {{{
|
||||
var rows *sqlx.Rows
|
||||
rows, err = service.Db.Conn.Queryx(`
|
||||
SELECT
|
||||
dp.id,
|
||||
dp.name,
|
||||
dp.datatype,
|
||||
dp.last_value,
|
||||
dp.group,
|
||||
dp.comment,
|
||||
dp.nodata_problem_seconds,
|
||||
|
||||
dpv.id AS v_id,
|
||||
dpv.ts,
|
||||
dpv.value_int,
|
||||
dpv.value_string,
|
||||
dpv.value_datetime
|
||||
|
||||
FROM public.datapoint dp
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
*,
|
||||
row_number() OVER (PARTITION BY "datapoint_id" ORDER BY ts DESC) AS rn
|
||||
FROM datapoint_value
|
||||
) dpv ON dpv.datapoint_id = dp.id AND rn = 1
|
||||
id, name, datatype, last_value, "group", comment, nodata_problem_seconds,
|
||||
last_value_id AS v_id,
|
||||
CASE
|
||||
WHEN last_value_id IS NULL THEN null
|
||||
ELSE last_value
|
||||
END AS ts,
|
||||
last_value_int AS value_int,
|
||||
last_value_string AS value_string,
|
||||
last_value_datetime AS value_datetime
|
||||
FROM datapoint
|
||||
ORDER BY
|
||||
dp.group ASC,
|
||||
dp.name ASC
|
||||
"group" ASC,
|
||||
name ASC
|
||||
`)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err)
|
||||
@ -255,11 +242,11 @@ func DatapointRetrieve(id int, name string) (dp Datapoint, err error) { // {{{
|
||||
var query string
|
||||
var param any
|
||||
if id > 0 {
|
||||
query = `SELECT *, true AS found FROM datapoint WHERE id = $1`
|
||||
query = `SELECT id, "group", name, "datatype", comment, last_value, nodata_problem_seconds, nodata_is_problem, true AS found FROM public.datapoint WHERE id = $1`
|
||||
param = id
|
||||
dp.ID = id
|
||||
} else {
|
||||
query = `SELECT *, true AS found FROM datapoint WHERE name = $1`
|
||||
query = `SELECT id, "group", name, "datatype", comment, last_value, nodata_problem_seconds, nodata_is_problem, true AS found FROM public.datapoint WHERE name = $1`
|
||||
param = name
|
||||
}
|
||||
|
||||
|
2
go.mod
@ -3,7 +3,7 @@ module smon
|
||||
go 1.22.0
|
||||
|
||||
require (
|
||||
git.gibonuddevalla.se/go/webservice v0.2.15
|
||||
git.gibonuddevalla.se/go/webservice v0.2.16
|
||||
git.gibonuddevalla.se/go/wrappederror v0.3.4
|
||||
github.com/expr-lang/expr v1.16.5
|
||||
github.com/jmoiron/sqlx v1.3.5
|
||||
|
@ -63,7 +63,7 @@ func applyTimeOffset(t time.Time, duration time.Duration, amountStr string) time
|
||||
|
||||
return t.Add(duration * time.Duration(amount))
|
||||
} // }}}
|
||||
func presetTimeInterval(duration time.Duration, presetStr string, timeFrom, timeTo *time.Time) () {
|
||||
func presetTimeInterval(duration time.Duration, presetStr string, timeFrom, timeTo *time.Time) () {// {{{
|
||||
if presetStr == "" {
|
||||
return
|
||||
}
|
||||
@ -73,4 +73,4 @@ func presetTimeInterval(duration time.Duration, presetStr string, timeFrom, time
|
||||
(*timeFrom) = now.Add(duration * -1 * time.Duration(presetTime))
|
||||
(*timeTo) = now
|
||||
return
|
||||
}
|
||||
}// }}}
|
||||
|
180
main.go
@ -29,7 +29,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const VERSION = "v26"
|
||||
const VERSION = "v41"
|
||||
|
||||
var (
|
||||
logger *slog.Logger
|
||||
@ -521,7 +521,29 @@ func pageProblems(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
CONFIG: smonConfig.Settings,
|
||||
}
|
||||
|
||||
problems, err := ProblemsRetrieve()
|
||||
// Manage the values from the timefilter component
|
||||
var err error
|
||||
var timeFrom, timeTo time.Time
|
||||
timeFrom, timeTo, err = timefilterParse(
|
||||
r.URL.Query().Get("time-f"),
|
||||
r.URL.Query().Get("time-t"),
|
||||
r.URL.Query().Get("time-offset"),
|
||||
r.URL.Query().Get("time-preset"),
|
||||
)
|
||||
if err != nil {
|
||||
httpError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// GET parameters for this page
|
||||
var selection string
|
||||
if r.URL.Query().Get("selection") == "all" {
|
||||
selection = "ALL"
|
||||
} else {
|
||||
selection = "CURRENT"
|
||||
}
|
||||
|
||||
problems, err := ProblemsRetrieve(selection == "ALL", timeFrom, timeTo)
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).Log())
|
||||
return
|
||||
@ -546,6 +568,12 @@ func pageProblems(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
page.Data = map[string]any{
|
||||
"Problems": problems,
|
||||
"ProblemsGrouped": problemsGrouped,
|
||||
"Selection": selection,
|
||||
|
||||
"TimeHidden": selection == "CURRENT",
|
||||
"TimeSubmit": "/problems",
|
||||
"TimeFrom": timeFrom.Format("2006-01-02T15:04:05"),
|
||||
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
|
||||
}
|
||||
page.Render(w, r)
|
||||
return
|
||||
@ -635,6 +663,27 @@ func pageDatapointEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { /
|
||||
}
|
||||
}
|
||||
|
||||
/* Triggers using this datapoint is provided as a list to update
|
||||
* if changing the datapoint name. Parsing expr and automatically
|
||||
* changing it to renamed datapoints would be nice in the future. */
|
||||
var triggers []Trigger
|
||||
triggers, err = TriggersRetrieveByDatapoint(datapoint.Name)
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).Log())
|
||||
return
|
||||
}
|
||||
slices.SortFunc(triggers, func(a, b Trigger) int {
|
||||
an := strings.ToUpper(a.Name)
|
||||
bn := strings.ToUpper(b.Name)
|
||||
if an < bn {
|
||||
return -1
|
||||
}
|
||||
if an > bn {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
page := Page{
|
||||
LAYOUT: "main",
|
||||
PAGE: "datapoint_edit",
|
||||
@ -646,6 +695,7 @@ func pageDatapointEdit(w http.ResponseWriter, r *http.Request, _ *session.T) { /
|
||||
|
||||
page.Data = map[string]any{
|
||||
"Datapoint": datapoint,
|
||||
"Triggers": triggers,
|
||||
}
|
||||
page.Render(w, r)
|
||||
return
|
||||
@ -661,8 +711,15 @@ func actionDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T)
|
||||
var nodataSeconds int
|
||||
nodataSeconds, _ = strconv.Atoi(r.FormValue("nodata_seconds"))
|
||||
|
||||
// Datapoint needs to be retrieved from database for the name.
|
||||
// If name has changed, trigger expressions needs to be updated.
|
||||
var dp Datapoint
|
||||
dp.ID = id
|
||||
dp, err = DatapointRetrieve(id, "")
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).WithData(id).Log())
|
||||
return
|
||||
}
|
||||
prevDatapointName := dp.Name
|
||||
dp.Group = r.FormValue("group")
|
||||
dp.Name = r.FormValue("name")
|
||||
dp.Datatype = DatapointType(r.FormValue("datatype"))
|
||||
@ -674,6 +731,29 @@ func actionDatapointUpdate(w http.ResponseWriter, r *http.Request, _ *session.T)
|
||||
return
|
||||
}
|
||||
|
||||
// Update the trigger expressions using this
|
||||
// datapoint name if changed.
|
||||
if prevDatapointName != dp.Name {
|
||||
var triggers []Trigger
|
||||
triggers, err = TriggersRetrieveByDatapoint(dp.Name)
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).WithData(dp.Name))
|
||||
return
|
||||
}
|
||||
for _, trigger := range triggers {
|
||||
err = trigger.RenameDatapoint(prevDatapointName, dp.Name)
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).WithData([]string{prevDatapointName, dp.Name}))
|
||||
return
|
||||
}
|
||||
err = trigger.Update()
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).WithData([]string{prevDatapointName, dp.Name, trigger.Name}))
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Add("Location", "/datapoints")
|
||||
w.WriteHeader(302)
|
||||
} // }}}
|
||||
@ -709,31 +789,14 @@ 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"
|
||||
}
|
||||
|
||||
// Manage the values from the timefilter component
|
||||
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).WithData(r.URL.Query().Get("f")).Log())
|
||||
return
|
||||
}
|
||||
|
||||
timeTo, err = parseHTMLDateTime(r.URL.Query().Get("t"), time.Now())
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).WithData(r.URL.Query().Get("t")).Log())
|
||||
return
|
||||
}
|
||||
|
||||
presetTimeInterval(time.Hour, r.URL.Query().Get("preset"), &timeFrom, &timeTo)
|
||||
|
||||
// Apply an optionally set offset (in seconds).
|
||||
timeFrom = applyTimeOffset(timeFrom, time.Second, r.URL.Query().Get("offset-time"))
|
||||
timeTo = applyTimeOffset(timeTo, time.Second, r.URL.Query().Get("offset-time"))
|
||||
timeFrom, timeTo, err = timefilterParse(
|
||||
r.URL.Query().Get("time-f"),
|
||||
r.URL.Query().Get("time-t"),
|
||||
r.URL.Query().Get("time-offset"),
|
||||
r.URL.Query().Get("time-preset"),
|
||||
)
|
||||
|
||||
// Fetch data point values according to the times.
|
||||
var values []DatapointValue
|
||||
@ -743,6 +806,12 @@ 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"
|
||||
}
|
||||
|
||||
page := Page{
|
||||
LAYOUT: "main",
|
||||
PAGE: "datapoint_values",
|
||||
@ -755,9 +824,11 @@ 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,
|
||||
|
||||
"TimeSubmit": "/datapoint/values/" + strconv.Itoa(datapoint.ID),
|
||||
"TimeFrom": timeFrom.Format("2006-01-02T15:04:05"),
|
||||
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
|
||||
}
|
||||
page.Render(w, r)
|
||||
return
|
||||
@ -770,14 +841,14 @@ func actionDatapointJson(w http.ResponseWriter, r *http.Request, _ *session.T) {
|
||||
return
|
||||
}
|
||||
|
||||
fromStr := r.URL.Query().Get("f")
|
||||
fromStr := r.URL.Query().Get("time-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")
|
||||
toStr := r.URL.Query().Get("time-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())
|
||||
@ -932,7 +1003,22 @@ func actionTriggerDatapointAdd(w http.ResponseWriter, r *http.Request, _ *sessio
|
||||
return
|
||||
}
|
||||
|
||||
j, _ := json.Marshal(struct{ OK bool }{OK: true})
|
||||
// Also retrieve the datapoint to get the latest value
|
||||
// for immediate presentation when added.
|
||||
dp, err := DatapointRetrieve(0, dpName)
|
||||
if err != nil {
|
||||
httpError(w, werr.Wrap(err).WithData(dpName).Log())
|
||||
return
|
||||
}
|
||||
dp.LastDatapointValue.TemplateValue = dp.LastDatapointValue.Value()
|
||||
j, _ := json.Marshal(
|
||||
struct {
|
||||
OK bool
|
||||
Datapoint Datapoint
|
||||
}{
|
||||
true,
|
||||
dp,
|
||||
})
|
||||
w.Header().Add("Content-Type", "application/json")
|
||||
w.Write(j)
|
||||
} // }}}
|
||||
@ -1177,6 +1263,8 @@ func actionConfigurationNotificationUpdate(w http.ResponseWriter, r *http.Reques
|
||||
notificationManager.AddService(*svc)
|
||||
}
|
||||
|
||||
notificationManager.Reprioritize()
|
||||
|
||||
w.Header().Add("Location", "/configuration")
|
||||
w.WriteHeader(302)
|
||||
} // }}}
|
||||
@ -1198,27 +1286,14 @@ func actionConfigurationNotificationDelete(w http.ResponseWriter, r *http.Reques
|
||||
func pageNotifications(w http.ResponseWriter, r *http.Request, _ *session.T) { // {{{
|
||||
var err error
|
||||
|
||||
// GET parameters.
|
||||
// Manage the values from the timefilter component
|
||||
var timeFrom, timeTo time.Time
|
||||
lastWeek := time.Now().Add(time.Duration(-7 * 24 * time.Hour))
|
||||
|
||||
timeFrom, err = parseHTMLDateTime(r.URL.Query().Get("f"), lastWeek)
|
||||
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
|
||||
}
|
||||
|
||||
presetTimeInterval(time.Hour, r.URL.Query().Get("preset"), &timeFrom, &timeTo)
|
||||
|
||||
// Apply an optionally set offset (in seconds).
|
||||
timeFrom = applyTimeOffset(timeFrom, time.Second, r.URL.Query().Get("offset-time"))
|
||||
timeTo = applyTimeOffset(timeTo, time.Second, r.URL.Query().Get("offset-time"))
|
||||
timeFrom, timeTo, err = timefilterParse(
|
||||
r.URL.Query().Get("time-f"),
|
||||
r.URL.Query().Get("time-t"),
|
||||
r.URL.Query().Get("time-offset"),
|
||||
r.URL.Query().Get("time-preset"),
|
||||
)
|
||||
|
||||
nss, err := notificationsSent(timeFrom, timeTo)
|
||||
if err != nil {
|
||||
@ -1231,6 +1306,7 @@ func pageNotifications(w http.ResponseWriter, r *http.Request, _ *session.T) { /
|
||||
CONFIG: smonConfig.Settings,
|
||||
Data: map[string]any{
|
||||
"Notifications": nss,
|
||||
"TimeSubmit": "/notifications",
|
||||
"TimeFrom": timeFrom.Format("2006-01-02T15:04:05"),
|
||||
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
|
||||
},
|
||||
|
@ -24,6 +24,16 @@ func ServiceFactory(t string, config []byte, prio int, ackURL string, logger *sl
|
||||
}
|
||||
ntfy.SetLogger(logger)
|
||||
return ntfy, nil
|
||||
|
||||
case "PUSHOVER":
|
||||
pushover, err := NewPushover(config, prio, ackURL)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithData(config)
|
||||
return nil, err
|
||||
}
|
||||
pushover.SetLogger(logger)
|
||||
return pushover, nil
|
||||
|
||||
case "SCRIPT":
|
||||
script, err := NewScript(config, prio, ackURL)
|
||||
if err != nil {
|
||||
@ -41,6 +51,8 @@ func NewInstance(typ string) Service {
|
||||
switch typ {
|
||||
case "NTFY":
|
||||
return new(NTFY)
|
||||
case "PUSHOVER":
|
||||
return new(Pushover)
|
||||
case "SCRIPT":
|
||||
return new(Script)
|
||||
}
|
||||
|
@ -81,7 +81,7 @@ func (ntfy NTFY) Send(problemID int, msg []byte) (err error) {
|
||||
|
||||
ackURL := fmt.Sprintf("http, OK, %s/notification/ack?problemID=%d", ntfy.AcknowledgeURL, problemID)
|
||||
req.Header.Add("X-Actions", ackURL)
|
||||
req.Header.Add("X-Priority", "4") // XXX: should be 5
|
||||
req.Header.Add("X-Priority", "5")
|
||||
req.Header.Add("X-Tags", "calendar")
|
||||
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
|
@ -39,6 +39,9 @@ func NewManager(logger *slog.Logger) (nm Manager) {
|
||||
func (nm *Manager) AddService(service Service) {
|
||||
service.SetExists(true)
|
||||
nm.services = append(nm.services, service)
|
||||
}
|
||||
|
||||
func (nm *Manager) Reprioritize() {
|
||||
slices.SortFunc(nm.services, func(a, b Service) int {
|
||||
if a.GetPrio() < b.GetPrio() {
|
||||
return -1
|
||||
|
198
notification/pushover.go
Normal file
@ -0,0 +1,198 @@
|
||||
package notification
|
||||
|
||||
import (
|
||||
// External
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
|
||||
// Standard
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Pushover struct {
|
||||
Description string
|
||||
UserKey string `json:"user_key"`
|
||||
APIKey string `json:"api_key"`
|
||||
DeviceName string `json:"device_name"`
|
||||
|
||||
Prio int
|
||||
AcknowledgeURL string
|
||||
logger *slog.Logger
|
||||
|
||||
exists bool
|
||||
updated Service
|
||||
}
|
||||
|
||||
func init() {
|
||||
allServices = append(allServices, &Pushover{})
|
||||
}
|
||||
|
||||
func NewPushover(config []byte, prio int, ackURL string) (instance *Pushover, err error) {
|
||||
instance = new(Pushover)
|
||||
err = json.Unmarshal(config, &instance)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithData(config)
|
||||
return
|
||||
}
|
||||
instance.Prio = prio
|
||||
instance.AcknowledgeURL = ackURL
|
||||
return instance, nil
|
||||
}
|
||||
|
||||
func (po *Pushover) SetLogger(l *slog.Logger) {
|
||||
po.logger = l
|
||||
}
|
||||
|
||||
func (po *Pushover) GetType() string {
|
||||
return "PUSHOVER"
|
||||
}
|
||||
|
||||
func (po *Pushover) GetPrio() int {
|
||||
return po.Prio
|
||||
}
|
||||
|
||||
func (po *Pushover) SetPrio(prio int) {
|
||||
po.Prio = prio
|
||||
}
|
||||
|
||||
func (po *Pushover) SetExists(exists bool) {
|
||||
po.exists = exists
|
||||
}
|
||||
|
||||
func (po Pushover) Exists() bool {
|
||||
return po.exists
|
||||
}
|
||||
|
||||
func (po *Pushover) String() string {
|
||||
if po.Description != "" {
|
||||
return po.Description
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s, %s", po.UserKey, po.APIKey)
|
||||
}
|
||||
|
||||
func (po Pushover) Send(problemID int, msg []byte) (err error) {
|
||||
var req *http.Request
|
||||
var res *http.Response
|
||||
|
||||
pushoverRequest, _ := json.Marshal(map[string]string{
|
||||
"token": po.APIKey,
|
||||
"user": po.UserKey,
|
||||
"device": po.DeviceName,
|
||||
"message": string(msg),
|
||||
})
|
||||
|
||||
req, err = http.NewRequest("POST", "https://api.pushover.net/1/messages.json", bytes.NewReader(pushoverRequest))
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithData(struct {
|
||||
UserKey string
|
||||
APIKey string
|
||||
Msg []byte
|
||||
}{
|
||||
po.UserKey,
|
||||
po.APIKey,
|
||||
msg,
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
//ackURL := fmt.Sprintf("http, OK, %s/notification/ack?problemID=%d", po.AcknowledgeURL, problemID)
|
||||
req.Header.Add("Content-Type", "application/json")
|
||||
res, err = http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
body, _ := io.ReadAll(res.Body)
|
||||
poResp := struct {
|
||||
Status int
|
||||
Errors []string
|
||||
}{}
|
||||
err = json.Unmarshal(body, &poResp)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithData(body)
|
||||
return
|
||||
}
|
||||
|
||||
if poResp.Status != 1 {
|
||||
err = werr.New("%s", strings.Join(poResp.Errors, ", "))
|
||||
return
|
||||
}
|
||||
|
||||
if res.StatusCode != 200 {
|
||||
err = werr.New("Invalid Pushover response").WithData(body)
|
||||
return
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (po *Pushover) Update(values url.Values) (err error) {
|
||||
updated := Pushover{}
|
||||
po.updated = &updated
|
||||
|
||||
// Prio
|
||||
updated.Prio, err = strconv.Atoi(values.Get("prio"))
|
||||
if err != nil {
|
||||
return werr.Wrap(err)
|
||||
}
|
||||
|
||||
// Description
|
||||
updated.Description = strings.TrimSpace(values.Get("description"))
|
||||
|
||||
// API (application) key
|
||||
givenAPIKey := values.Get("api_key")
|
||||
if strings.TrimSpace(givenAPIKey) == "" {
|
||||
return werr.New("API key cannot be empty")
|
||||
}
|
||||
updated.APIKey = strings.TrimSpace(givenAPIKey)
|
||||
|
||||
// User key
|
||||
givenUserKey := values.Get("user_key")
|
||||
if strings.TrimSpace(givenUserKey) == "" {
|
||||
return werr.New("User key cannot be empty")
|
||||
}
|
||||
updated.UserKey = strings.TrimSpace(givenUserKey)
|
||||
|
||||
// Device name
|
||||
updated.DeviceName = strings.TrimSpace(values.Get("device_name"))
|
||||
return
|
||||
}
|
||||
|
||||
func (po *Pushover) Updated() Service {
|
||||
return po.updated
|
||||
}
|
||||
|
||||
func (po *Pushover) Commit() {
|
||||
updatedPushover := po.updated.(*Pushover)
|
||||
po.Prio = updatedPushover.Prio
|
||||
po.Description = updatedPushover.Description
|
||||
po.APIKey = updatedPushover.APIKey
|
||||
po.UserKey = updatedPushover.UserKey
|
||||
po.DeviceName = updatedPushover.DeviceName
|
||||
}
|
||||
|
||||
func (po Pushover) JSON() []byte {
|
||||
data := struct {
|
||||
Description string
|
||||
APIKey string `json:"api_key"`
|
||||
UserKey string `json:"user_key"`
|
||||
DeviceName string `json:"device_name"`
|
||||
}{
|
||||
po.Description,
|
||||
po.APIKey,
|
||||
po.UserKey,
|
||||
po.DeviceName,
|
||||
}
|
||||
j, _ := json.Marshal(data)
|
||||
return j
|
||||
}
|
@ -101,7 +101,7 @@ func UpdateNotificationService(svc notification.Service) (created bool, err erro
|
||||
// Check if this is just a duplicated prio, which isn't allowed.
|
||||
pgErr, isPgErr := err.(*pq.Error)
|
||||
if isPgErr && pgErr.Code == "23505" {
|
||||
return false, werr.New("Prio %d is already used by another service", svc.GetPrio())
|
||||
return false, werr.New("Prio %d is already used by another service", svc.Updated().GetPrio())
|
||||
}
|
||||
|
||||
return false, werr.Wrap(err).WithData(
|
||||
|
6
page.go
@ -2,7 +2,7 @@ package main
|
||||
|
||||
import (
|
||||
// External
|
||||
we "git.gibonuddevalla.se/go/wrappederror"
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
|
||||
// Standard
|
||||
"fmt"
|
||||
@ -25,7 +25,7 @@ type Page struct {
|
||||
func (p *Page) Render(w http.ResponseWriter, r *http.Request) {
|
||||
tmpl, err := getPage(p.LAYOUT, p.PAGE)
|
||||
if err != nil {
|
||||
httpError(w, we.Wrap(err).Log())
|
||||
httpError(w, werr.Wrap(err).Log())
|
||||
return
|
||||
}
|
||||
|
||||
@ -58,6 +58,6 @@ func (p *Page) Render(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
err = tmpl.Execute(w, data)
|
||||
if err != nil {
|
||||
httpError(w, we.Wrap(err).Log())
|
||||
httpError(w, werr.Wrap(err).Log())
|
||||
}
|
||||
}
|
||||
|
136
problem.go
@ -2,26 +2,31 @@ package main
|
||||
|
||||
import (
|
||||
// External
|
||||
we "git.gibonuddevalla.se/go/wrappederror"
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
|
||||
// Standard
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Problem struct {
|
||||
ID int
|
||||
Start time.Time
|
||||
End sql.NullTime
|
||||
Acknowledged bool
|
||||
TriggerID int `json:"trigger_id"`
|
||||
TriggerName string `json:"trigger_name"`
|
||||
AreaName string `json:"area_name"`
|
||||
SectionName string `json:"section_name"`
|
||||
}
|
||||
type Problem struct { // {{{
|
||||
ID int
|
||||
Start time.Time
|
||||
End time.Time
|
||||
Acknowledged bool
|
||||
Datapoints map[string]any
|
||||
DatapointValues map[string]any `json:"datapoints"`
|
||||
TriggerID int `json:"trigger_id"`
|
||||
TriggerName string `json:"trigger_name"`
|
||||
AreaName string `json:"area_name"`
|
||||
SectionName string `json:"section_name"`
|
||||
} // }}}
|
||||
|
||||
func ProblemsRetrieve() (problems []Problem, err error) {
|
||||
func ProblemsRetrieve(showCurrent bool, from, to time.Time) (problems []Problem, err error) { // {{{
|
||||
problems = []Problem{}
|
||||
row := service.Db.Conn.QueryRow(`
|
||||
SELECT
|
||||
@ -30,28 +35,39 @@ func ProblemsRetrieve() (problems []Problem, err error) {
|
||||
(SELECT
|
||||
p.id,
|
||||
p.start,
|
||||
p.end,
|
||||
TO_CHAR(p.end, 'YYYY-MM-DD"T"HH24:MI:SSTZH:TZM') AS end,
|
||||
p.acknowledged,
|
||||
p.datapoints,
|
||||
t.id AS trigger_id,
|
||||
t.name AS trigger_name,
|
||||
p.trigger_name AS trigger_name,
|
||||
a.name AS area_name,
|
||||
s.name AS section_name
|
||||
FROM problem p
|
||||
INNER JOIN "trigger" t ON p.trigger_id = t.id
|
||||
INNER JOIN section s ON t.section_id = s.id
|
||||
INNER JOIN area a ON s.area_id = a.id
|
||||
LEFT JOIN "trigger" t ON p.trigger_id = t.id
|
||||
LEFT JOIN section s ON t.section_id = s.id
|
||||
LEFT JOIN area a ON s.area_id = a.id
|
||||
WHERE
|
||||
p.end IS NULL
|
||||
CASE
|
||||
WHEN NOT $1 THEN p.end IS NULL
|
||||
WHEN $1 THEN
|
||||
p.start >= $2 AND
|
||||
(
|
||||
p.end IS NULL OR
|
||||
p.end <= $3
|
||||
)
|
||||
END
|
||||
|
||||
ORDER BY
|
||||
p.start DESC)
|
||||
|
||||
UNION ALL
|
||||
|
||||
|
||||
(SELECT
|
||||
-1 AS id,
|
||||
null,
|
||||
dp.last_value,
|
||||
null,
|
||||
false,
|
||||
'{}',
|
||||
-1 AS trigger_id,
|
||||
CONCAT(
|
||||
'NODATA: ',
|
||||
@ -64,13 +80,16 @@ func ProblemsRetrieve() (problems []Problem, err error) {
|
||||
dp.nodata_is_problem
|
||||
ORDER BY
|
||||
dp.name ASC)
|
||||
) AS problems
|
||||
`)
|
||||
) AS problems`,
|
||||
showCurrent,
|
||||
from,
|
||||
to,
|
||||
)
|
||||
|
||||
var jsonBody []byte
|
||||
err = row.Scan(&jsonBody)
|
||||
if err != nil {
|
||||
err = we.Wrap(err)
|
||||
err = werr.Wrap(err)
|
||||
return
|
||||
}
|
||||
|
||||
@ -80,12 +99,11 @@ func ProblemsRetrieve() (problems []Problem, err error) {
|
||||
|
||||
err = json.Unmarshal(jsonBody, &problems)
|
||||
if err != nil {
|
||||
err = we.Wrap(err)
|
||||
err = werr.Wrap(err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func ProblemStart(trigger Trigger) (problemID int, err error) {
|
||||
} // }}}
|
||||
func ProblemStart(trigger Trigger) (problemID int, err error) { // {{{
|
||||
row := service.Db.Conn.QueryRow(`
|
||||
SELECT COUNT(id)
|
||||
FROM problem
|
||||
@ -99,22 +117,28 @@ func ProblemStart(trigger Trigger) (problemID int, err error) {
|
||||
var openProblems int
|
||||
err = row.Scan(&openProblems)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
err = we.Wrap(err).WithData(trigger.ID)
|
||||
err = werr.Wrap(err).WithData(trigger.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// Open up a new problem if no open exists.
|
||||
if openProblems == 0 {
|
||||
row = service.Db.Conn.QueryRow(`INSERT INTO problem(trigger_id) VALUES($1) RETURNING id`, trigger.ID)
|
||||
datapointValuesJson, _ := json.Marshal(trigger.DatapointValues)
|
||||
row = service.Db.Conn.QueryRow(
|
||||
`INSERT INTO problem(trigger_id, trigger_name, datapoints, trigger_expression) VALUES($1, $2, $3, $4) RETURNING id`,
|
||||
trigger.ID,
|
||||
trigger.Name,
|
||||
datapointValuesJson,
|
||||
trigger.Expression,
|
||||
)
|
||||
err = row.Scan(&problemID)
|
||||
if err != nil {
|
||||
err = we.Wrap(err).WithData(trigger)
|
||||
err = werr.Wrap(err).WithData(trigger)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func ProblemClose(trigger Trigger) (problemID int, err error) {
|
||||
} // }}}
|
||||
func ProblemClose(trigger Trigger) (problemID int, err error) { // {{{
|
||||
row := service.Db.Conn.QueryRow(`UPDATE problem SET "end"=NOW() WHERE trigger_id=$1 AND "end" IS NULL RETURNING id`, trigger.ID)
|
||||
err = row.Scan(&problemID)
|
||||
|
||||
@ -124,17 +148,51 @@ func ProblemClose(trigger Trigger) (problemID int, err error) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
err = we.Wrap(err).WithData(trigger)
|
||||
err = werr.Wrap(err).WithData(trigger)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func ProblemAcknowledge(id int, state bool) (err error) {
|
||||
} // }}}
|
||||
func ProblemAcknowledge(id int, state bool) (err error) { // {{{
|
||||
_, err = service.Db.Conn.Exec(`UPDATE problem SET "acknowledged"=$2 WHERE id=$1`, id, state)
|
||||
if err != nil {
|
||||
err = we.Wrap(err).WithData(id)
|
||||
err = werr.Wrap(err).WithData(id)
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
} // }}}
|
||||
|
||||
func (p Problem) FormattedValues() string { // {{{
|
||||
out := []string{}
|
||||
for key, val := range p.DatapointValues {
|
||||
var keyval string
|
||||
|
||||
switch val.(type) {
|
||||
case int:
|
||||
keyval = fmt.Sprintf("%s: %d", key, val)
|
||||
|
||||
case string:
|
||||
if str, ok := val.(string); ok {
|
||||
timeVal, err := time.Parse(time.RFC3339, str)
|
||||
if err == nil {
|
||||
formattedTime := timeVal.Format("2006-01-02 15:04:05")
|
||||
keyval = fmt.Sprintf("%s: %s", key, formattedTime)
|
||||
} else {
|
||||
keyval = fmt.Sprintf("%s: %s", key, val)
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
keyval = fmt.Sprintf("%s: %v", key, val)
|
||||
}
|
||||
|
||||
out = append(out, keyval)
|
||||
|
||||
}
|
||||
sort.Strings(out)
|
||||
|
||||
return strings.Join(out, "\n")
|
||||
} // }}}
|
||||
func (p Problem) IsArchived() bool { // {{{
|
||||
return !p.End.IsZero()
|
||||
} // }}}
|
||||
|
BIN
screenshots/datapoint_values.jpg
Normal file
After Width: | Height: | Size: 118 KiB |
BIN
screenshots/datapoint_values_small.jpg
Normal file
After Width: | Height: | Size: 16 KiB |
BIN
screenshots/datapoints.jpg
Normal file
After Width: | Height: | Size: 114 KiB |
BIN
screenshots/datapoints_small.jpg
Normal file
After Width: | Height: | Size: 13 KiB |
BIN
screenshots/problems.jpg
Normal file
After Width: | Height: | Size: 132 KiB |
BIN
screenshots/problems_small.jpg
Normal file
After Width: | Height: | Size: 8.9 KiB |
BIN
screenshots/triggers.jpg
Normal file
After Width: | Height: | Size: 64 KiB |
BIN
screenshots/triggers_small.jpg
Normal file
After Width: | Height: | Size: 15 KiB |
4
sql/00022.sql
Normal file
@ -0,0 +1,4 @@
|
||||
ALTER TABLE public.problem ALTER COLUMN trigger_id DROP NOT NULL;
|
||||
ALTER TABLE public.problem ADD COLUMN datapoints JSONB NOT NULL DEFAULT '{}';
|
||||
ALTER TABLE public.problem DROP CONSTRAINT problem_trigger_fk;
|
||||
ALTER TABLE public.problem ADD CONSTRAINT problem_trigger_fk FOREIGN KEY (trigger_id) REFERENCES public."trigger"(id) ON DELETE SET NULL ON UPDATE CASCADE;
|
1
sql/00023.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE public.problem ADD COLUMN trigger_expression VARCHAR NOT NULL DEFAULT '';
|
1
sql/00024.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE public.problem ADD COLUMN trigger_name VARCHAR NOT NULL DEFAULT '[Unknown]';
|
1
sql/00025.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TYPE notification_type ADD VALUE 'PUSHOVER';
|
62
sql/00026.sql
Normal file
@ -0,0 +1,62 @@
|
||||
/* Adding last values to the datapoint table since they are a regularly used value. */
|
||||
ALTER TABLE public.datapoint ADD COLUMN last_value_id int4 NULL;
|
||||
ALTER TABLE public.datapoint ADD COLUMN last_value_int int8 NULL;
|
||||
ALTER TABLE public.datapoint ADD COLUMN last_value_string varchar NULL;
|
||||
ALTER TABLE public.datapoint ADD COLUMN last_value_datetime timestamptz NULL;
|
||||
|
||||
|
||||
|
||||
/* Once-run query to update it to the latest, to avoid user having to wait for the next entry. */
|
||||
UPDATE public.datapoint AS dp
|
||||
SET
|
||||
last_value_id = dpv.id,
|
||||
last_value_int = dpv.value_int,
|
||||
last_value_string = dpv.value_string,
|
||||
last_value_datetime = dpv.value_datetime
|
||||
FROM (
|
||||
SELECT
|
||||
dp.id AS datapoint_id,
|
||||
dpv.id,
|
||||
dpv.value_int,
|
||||
dpv.value_string,
|
||||
dpv.value_datetime
|
||||
FROM public.datapoint dp
|
||||
LEFT JOIN (
|
||||
SELECT
|
||||
*,
|
||||
row_number() OVER (PARTITION BY "datapoint_id" ORDER BY ts DESC) AS rn
|
||||
FROM datapoint_value
|
||||
) dpv ON dpv.datapoint_id = dp.id AND rn = 1
|
||||
) AS dpv
|
||||
WHERE
|
||||
dpv.datapoint_id = dp.id;
|
||||
|
||||
|
||||
|
||||
/* A trigger keeps the value current without bugs introduced in software missing the entry. */
|
||||
CREATE OR REPLACE FUNCTION datapoint_entry()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
UPDATE public.datapoint
|
||||
SET
|
||||
nodata_is_problem = false,
|
||||
last_value = NEW.ts,
|
||||
last_value_id = NEW.id,
|
||||
last_value_int = NEW.value_int,
|
||||
last_value_string = NEW.value_string,
|
||||
last_value_datetime = NEW.value_datetime
|
||||
WHERE
|
||||
id = NEW.datapoint_id;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER datapoint_entry
|
||||
AFTER INSERT
|
||||
ON public.datapoint_value
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE datapoint_entry();
|
22
sql/00027.sql
Normal file
@ -0,0 +1,22 @@
|
||||
/* Updating a datapoint name also updates the jsonb array entry */
|
||||
CREATE OR REPLACE FUNCTION update_triggers_datapoint_name()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE PLPGSQL
|
||||
AS
|
||||
$$
|
||||
BEGIN
|
||||
UPDATE "trigger"
|
||||
SET
|
||||
datapoints = (datapoints - OLD.name) || jsonb_build_array(NEW.name)
|
||||
WHERE
|
||||
datapoints ? OLD.name;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE TRIGGER datapoint_renamed
|
||||
AFTER UPDATE
|
||||
ON public.datapoint
|
||||
FOR EACH ROW
|
||||
EXECUTE PROCEDURE update_triggers_datapoint_name();
|
@ -1,3 +1,6 @@
|
||||
#datapoints-filter.invalid-regex {
|
||||
background-color: #ffd5d5;
|
||||
}
|
||||
#datapoints {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, min-content);
|
||||
@ -24,6 +27,9 @@
|
||||
font-size: 0.85em;
|
||||
color: #7bb8eb;
|
||||
}
|
||||
#datapoints .hidden {
|
||||
display: none !important;
|
||||
}
|
||||
#datapoints div {
|
||||
white-space: nowrap;
|
||||
align-self: center;
|
||||
@ -94,27 +100,5 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
.graph #graph-values {
|
||||
height: calc(100vh - 308px);
|
||||
}
|
||||
.time-offset {
|
||||
display: grid;
|
||||
grid-template-columns: min-content repeat(3, min-content);
|
||||
grid-gap: 16px;
|
||||
margin-top: 16px;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
.time-offset .header-1 {
|
||||
font-weight: bold;
|
||||
justify-self: start;
|
||||
}
|
||||
.time-offset .header-2 {
|
||||
font-weight: bold;
|
||||
justify-self: start;
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
.time-offset .preset {
|
||||
white-space: nowrap;
|
||||
justify-self: start;
|
||||
padding-right: 32px;
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
|
@ -49,7 +49,9 @@ dialog,
|
||||
#acknowledged-list,
|
||||
#values,
|
||||
#services,
|
||||
#notifications {
|
||||
#notifications,
|
||||
#group,
|
||||
.table {
|
||||
background-color: #fff !important;
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25);
|
||||
|
@ -13,3 +13,11 @@ body {
|
||||
background-color: #a00;
|
||||
text-align: center;
|
||||
}
|
||||
span.error {
|
||||
color: #f66;
|
||||
}
|
||||
input[type="datetime-local"] {
|
||||
background-color: #1b4e78;
|
||||
color: #ccc;
|
||||
border: 1px solid #535353;
|
||||
}
|
||||
|
@ -1,3 +1,28 @@
|
||||
.table {
|
||||
display: grid;
|
||||
grid-gap: 6px 16px;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
background-color: #2979b8;
|
||||
padding: 16px 24px;
|
||||
width: min-content;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
.table .row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.table > div {
|
||||
white-space: nowrap;
|
||||
line-height: 24px;
|
||||
}
|
||||
.table .header {
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
color: #7bb8eb;
|
||||
line-height: unset !important;
|
||||
}
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@ -213,6 +238,12 @@ span.time {
|
||||
span.seconds {
|
||||
display: none;
|
||||
}
|
||||
span.ok {
|
||||
color: #0a0;
|
||||
}
|
||||
span.error {
|
||||
color: #a00;
|
||||
}
|
||||
label {
|
||||
user-select: none;
|
||||
}
|
||||
@ -226,3 +257,35 @@ label {
|
||||
width: min-content;
|
||||
border-radius: 8px;
|
||||
}
|
||||
#time-selector {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 8px repeat(2, min-content) 8px min-content 8px 1px 8px repeat(3, min-content) 8px repeat(3, min-content) 8px 1px 8px repeat(2, min-content) 8px;
|
||||
grid-gap: 6px 8px;
|
||||
align-items: center;
|
||||
width: min-content;
|
||||
background-color: #fff;
|
||||
border: 1px solid #2979b8;
|
||||
border-radius: 6px;
|
||||
}
|
||||
#time-selector.hidden {
|
||||
display: none;
|
||||
}
|
||||
#time-selector .vertical-line {
|
||||
background-color: #2979b8;
|
||||
}
|
||||
#time-selector .header {
|
||||
padding-top: 12px;
|
||||
font-weight: bold;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
#time-selector button {
|
||||
width: 100px;
|
||||
margin-top: 12px;
|
||||
justify-self: end;
|
||||
}
|
||||
#time-selector div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
@ -1,45 +1,8 @@
|
||||
#time-select {
|
||||
display: grid;
|
||||
grid-template-columns: min-content min-content;
|
||||
grid-gap: 6px 16px;
|
||||
width: min-content;
|
||||
border-radius: 6px;
|
||||
}
|
||||
#time-select button {
|
||||
width: 100px;
|
||||
margin-top: 12px;
|
||||
justify-self: end;
|
||||
}
|
||||
#time-select #time-offsets {
|
||||
display: grid;
|
||||
grid-template-columns: min-content repeat(3, min-content);
|
||||
grid-gap: 16px;
|
||||
margin-top: 16px;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
#time-select #time-offsets .header-1 {
|
||||
font-weight: bold;
|
||||
justify-self: start;
|
||||
}
|
||||
#time-select #time-offsets .header-2 {
|
||||
font-weight: bold;
|
||||
justify-self: start;
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
#time-select #time-offsets .preset {
|
||||
white-space: nowrap;
|
||||
justify-self: start;
|
||||
padding-right: 32px;
|
||||
}
|
||||
input[type="datetime-local"] {
|
||||
padding: 6px;
|
||||
}
|
||||
#notifications {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, min-content);
|
||||
grid-gap: 4px 16px;
|
||||
margin-top: 32px;
|
||||
margin-top: 96px;
|
||||
margin-bottom: 32px;
|
||||
background-color: #2979b8;
|
||||
padding: 16px 24px;
|
||||
@ -55,9 +18,3 @@ input[type="datetime-local"] {
|
||||
font-weight: 800;
|
||||
color: #7bb8eb;
|
||||
}
|
||||
#notifications .ok {
|
||||
color: #0a0;
|
||||
}
|
||||
#notifications .error {
|
||||
color: #a00;
|
||||
}
|
||||
|
@ -1,57 +1,45 @@
|
||||
#problems-list,
|
||||
#acknowledged-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, min-content);
|
||||
grid-gap: 4px 16px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
background-color: #2979b8;
|
||||
padding: 16px 24px;
|
||||
width: min-content;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
#problems-list div,
|
||||
#acknowledged-list div {
|
||||
white-space: nowrap;
|
||||
line-height: 24px;
|
||||
}
|
||||
#problems-list .header,
|
||||
#acknowledged-list .header {
|
||||
font-weight: 800;
|
||||
color: #7bb8eb;
|
||||
grid-template-columns: repeat(7, min-content);
|
||||
}
|
||||
#problems-list .trigger,
|
||||
#acknowledged-list .trigger {
|
||||
color: #1b4e78;
|
||||
font-weight: 800;
|
||||
}
|
||||
#problems-list .acknowledge img,
|
||||
#acknowledged-list .acknowledge img {
|
||||
#problems-list img.acknowledge,
|
||||
#acknowledged-list img.acknowledge {
|
||||
height: 16px;
|
||||
}
|
||||
#problems-list .info,
|
||||
#acknowledged-list .info {
|
||||
margin-right: 8px;
|
||||
}
|
||||
#problems-list .icons,
|
||||
#acknowledged-list .icons {
|
||||
display: grid;
|
||||
grid-template-columns: min-content min-content;
|
||||
align-items: center;
|
||||
}
|
||||
#acknowledged-list.hidden {
|
||||
display: none;
|
||||
}
|
||||
#areas {
|
||||
#area-grouped {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
#areas .area .section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, min-content);
|
||||
grid-gap: 8px 12px;
|
||||
#area-grouped .area {
|
||||
grid-template-columns: repeat(5, min-content);
|
||||
}
|
||||
#areas .area .section .name {
|
||||
color: #000;
|
||||
grid-column: 1 / -1;
|
||||
font-weight: bold !important;
|
||||
line-height: 24px;
|
||||
}
|
||||
#areas .area .section div {
|
||||
white-space: nowrap;
|
||||
#area-grouped .area .section {
|
||||
padding: 4px 10px;
|
||||
border-radius: 5px;
|
||||
background: #2979b8;
|
||||
color: #fff;
|
||||
width: min-content;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
|
25
static/css/default_light/table.css
Normal file
@ -0,0 +1,25 @@
|
||||
.table {
|
||||
display: grid;
|
||||
grid-gap: 6px 16px;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
background-color: #2979b8;
|
||||
padding: 16px 24px;
|
||||
width: min-content;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
.table .row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.table > div {
|
||||
white-space: nowrap;
|
||||
line-height: 24px;
|
||||
}
|
||||
.table .header {
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
color: #7bb8eb;
|
||||
line-height: unset !important;
|
||||
}
|
@ -13,17 +13,24 @@
|
||||
.widgets .datapoints {
|
||||
font: "Roboto Mono", monospace;
|
||||
display: grid;
|
||||
grid-template-columns: min-content min-content 1fr;
|
||||
grid-template-columns: repeat(4, min-content);
|
||||
gap: 6px 8px;
|
||||
margin-bottom: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.widgets .datapoints div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.widgets .datapoints .invalid {
|
||||
color: #c83737;
|
||||
}
|
||||
.widgets .datapoints .delete img {
|
||||
height: 16px;
|
||||
}
|
||||
.widgets .datapoints .values img {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
.widgets .action {
|
||||
display: grid;
|
||||
grid-template-columns: min-content min-content 1fr;
|
||||
|
@ -1,3 +1,6 @@
|
||||
#datapoints-filter.invalid-regex {
|
||||
background-color: #ffd5d5;
|
||||
}
|
||||
#datapoints {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, min-content);
|
||||
@ -24,6 +27,9 @@
|
||||
font-size: 0.85em;
|
||||
color: #777;
|
||||
}
|
||||
#datapoints .hidden {
|
||||
display: none !important;
|
||||
}
|
||||
#datapoints div {
|
||||
white-space: nowrap;
|
||||
align-self: center;
|
||||
@ -94,27 +100,5 @@
|
||||
margin-top: 16px;
|
||||
}
|
||||
.graph #graph-values {
|
||||
height: calc(100vh - 308px);
|
||||
}
|
||||
.time-offset {
|
||||
display: grid;
|
||||
grid-template-columns: min-content repeat(3, min-content);
|
||||
grid-gap: 16px;
|
||||
margin-top: 16px;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
.time-offset .header-1 {
|
||||
font-weight: bold;
|
||||
justify-self: start;
|
||||
}
|
||||
.time-offset .header-2 {
|
||||
font-weight: bold;
|
||||
justify-self: start;
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
.time-offset .preset {
|
||||
white-space: nowrap;
|
||||
justify-self: start;
|
||||
padding-right: 32px;
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
|
@ -49,7 +49,9 @@ dialog,
|
||||
#acknowledged-list,
|
||||
#values,
|
||||
#services,
|
||||
#notifications {
|
||||
#notifications,
|
||||
#group,
|
||||
.table {
|
||||
background-color: #fff !important;
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: 5px 5px 8px 0px rgba(0, 0, 0, 0.25);
|
||||
|
@ -13,3 +13,11 @@ body {
|
||||
background-color: #a00;
|
||||
text-align: center;
|
||||
}
|
||||
span.error {
|
||||
color: #f66;
|
||||
}
|
||||
input[type="datetime-local"] {
|
||||
background-color: #202020;
|
||||
color: #ccc;
|
||||
border: 1px solid #535353;
|
||||
}
|
||||
|
@ -1,3 +1,28 @@
|
||||
.table {
|
||||
display: grid;
|
||||
grid-gap: 6px 16px;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
background-color: #333;
|
||||
padding: 16px 24px;
|
||||
width: min-content;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
.table .row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.table > div {
|
||||
white-space: nowrap;
|
||||
line-height: 24px;
|
||||
}
|
||||
.table .header {
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
color: #777;
|
||||
line-height: unset !important;
|
||||
}
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@ -213,6 +238,12 @@ span.time {
|
||||
span.seconds {
|
||||
display: none;
|
||||
}
|
||||
span.ok {
|
||||
color: #0a0;
|
||||
}
|
||||
span.error {
|
||||
color: #a00;
|
||||
}
|
||||
label {
|
||||
user-select: none;
|
||||
}
|
||||
@ -226,3 +257,35 @@ label {
|
||||
width: min-content;
|
||||
border-radius: 8px;
|
||||
}
|
||||
#time-selector {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: grid;
|
||||
grid-template-columns: 8px repeat(2, min-content) 8px min-content 8px 1px 8px repeat(3, min-content) 8px repeat(3, min-content) 8px 1px 8px repeat(2, min-content) 8px;
|
||||
grid-gap: 6px 8px;
|
||||
align-items: center;
|
||||
width: min-content;
|
||||
background-color: #282828;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
}
|
||||
#time-selector.hidden {
|
||||
display: none;
|
||||
}
|
||||
#time-selector .vertical-line {
|
||||
background-color: #333;
|
||||
}
|
||||
#time-selector .header {
|
||||
padding-top: 12px;
|
||||
font-weight: bold;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
#time-selector button {
|
||||
width: 100px;
|
||||
margin-top: 12px;
|
||||
justify-self: end;
|
||||
}
|
||||
#time-selector div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
@ -1,45 +1,8 @@
|
||||
#time-select {
|
||||
display: grid;
|
||||
grid-template-columns: min-content min-content;
|
||||
grid-gap: 6px 16px;
|
||||
width: min-content;
|
||||
border-radius: 6px;
|
||||
}
|
||||
#time-select button {
|
||||
width: 100px;
|
||||
margin-top: 12px;
|
||||
justify-self: end;
|
||||
}
|
||||
#time-select #time-offsets {
|
||||
display: grid;
|
||||
grid-template-columns: min-content repeat(3, min-content);
|
||||
grid-gap: 16px;
|
||||
margin-top: 16px;
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
}
|
||||
#time-select #time-offsets .header-1 {
|
||||
font-weight: bold;
|
||||
justify-self: start;
|
||||
}
|
||||
#time-select #time-offsets .header-2 {
|
||||
font-weight: bold;
|
||||
justify-self: start;
|
||||
grid-column: 2 / -1;
|
||||
}
|
||||
#time-select #time-offsets .preset {
|
||||
white-space: nowrap;
|
||||
justify-self: start;
|
||||
padding-right: 32px;
|
||||
}
|
||||
input[type="datetime-local"] {
|
||||
padding: 6px;
|
||||
}
|
||||
#notifications {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, min-content);
|
||||
grid-gap: 4px 16px;
|
||||
margin-top: 32px;
|
||||
margin-top: 96px;
|
||||
margin-bottom: 32px;
|
||||
background-color: #333;
|
||||
padding: 16px 24px;
|
||||
@ -55,9 +18,3 @@ input[type="datetime-local"] {
|
||||
font-weight: 800;
|
||||
color: #777;
|
||||
}
|
||||
#notifications .ok {
|
||||
color: #0a0;
|
||||
}
|
||||
#notifications .error {
|
||||
color: #a00;
|
||||
}
|
||||
|
@ -1,57 +1,45 @@
|
||||
#problems-list,
|
||||
#acknowledged-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, min-content);
|
||||
grid-gap: 4px 16px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
background-color: #333;
|
||||
padding: 16px 24px;
|
||||
width: min-content;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
#problems-list div,
|
||||
#acknowledged-list div {
|
||||
white-space: nowrap;
|
||||
line-height: 24px;
|
||||
}
|
||||
#problems-list .header,
|
||||
#acknowledged-list .header {
|
||||
font-weight: 800;
|
||||
color: #777;
|
||||
grid-template-columns: repeat(7, min-content);
|
||||
}
|
||||
#problems-list .trigger,
|
||||
#acknowledged-list .trigger {
|
||||
color: #fb4934;
|
||||
font-weight: 800;
|
||||
}
|
||||
#problems-list .acknowledge img,
|
||||
#acknowledged-list .acknowledge img {
|
||||
#problems-list img.acknowledge,
|
||||
#acknowledged-list img.acknowledge {
|
||||
height: 16px;
|
||||
}
|
||||
#problems-list .info,
|
||||
#acknowledged-list .info {
|
||||
margin-right: 8px;
|
||||
}
|
||||
#problems-list .icons,
|
||||
#acknowledged-list .icons {
|
||||
display: grid;
|
||||
grid-template-columns: min-content min-content;
|
||||
align-items: center;
|
||||
}
|
||||
#acknowledged-list.hidden {
|
||||
display: none;
|
||||
}
|
||||
#areas {
|
||||
#area-grouped {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
#areas .area .section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, min-content);
|
||||
grid-gap: 8px 12px;
|
||||
#area-grouped .area {
|
||||
grid-template-columns: repeat(5, min-content);
|
||||
}
|
||||
#areas .area .section .name {
|
||||
color: #f7edd7;
|
||||
grid-column: 1 / -1;
|
||||
font-weight: bold !important;
|
||||
line-height: 24px;
|
||||
}
|
||||
#areas .area .section div {
|
||||
white-space: nowrap;
|
||||
#area-grouped .area .section {
|
||||
padding: 4px 10px;
|
||||
border-radius: 5px;
|
||||
background: #333;
|
||||
color: #fff;
|
||||
width: min-content;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
|
25
static/css/gruvbox/table.css
Normal file
@ -0,0 +1,25 @@
|
||||
.table {
|
||||
display: grid;
|
||||
grid-gap: 6px 16px;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
background-color: #333;
|
||||
padding: 16px 24px;
|
||||
width: min-content;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
}
|
||||
.table .row {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.table > div {
|
||||
white-space: nowrap;
|
||||
line-height: 24px;
|
||||
}
|
||||
.table .header {
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
color: #777;
|
||||
line-height: unset !important;
|
||||
}
|
@ -13,17 +13,24 @@
|
||||
.widgets .datapoints {
|
||||
font: "Roboto Mono", monospace;
|
||||
display: grid;
|
||||
grid-template-columns: min-content min-content 1fr;
|
||||
grid-template-columns: repeat(4, min-content);
|
||||
gap: 6px 8px;
|
||||
margin-bottom: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.widgets .datapoints div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.widgets .datapoints .invalid {
|
||||
color: #c83737;
|
||||
}
|
||||
.widgets .datapoints .delete img {
|
||||
height: 16px;
|
||||
}
|
||||
.widgets .datapoints .values img {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
.widgets .action {
|
||||
display: grid;
|
||||
grid-template-columns: min-content min-content 1fr;
|
||||
|
69
static/images/default_light/forward.svg
Normal file
@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="19.999975"
|
||||
height="19.999975"
|
||||
viewBox="0 0 5.29166 5.2916603"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="forward.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="22.627417"
|
||||
inkscape:cx="9.3028736"
|
||||
inkscape:cy="12.772116"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="2190"
|
||||
inkscape:window-height="1404"
|
||||
inkscape:window-x="1463"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d6d6d6"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-123.825,-155.04583)">
|
||||
<title
|
||||
id="title1">alert</title>
|
||||
<title
|
||||
id="title1-1">arrow-right-bold-circle</title>
|
||||
<path
|
||||
d="m 123.825,157.69166 a 2.6458334,2.6458334 0 0 1 2.64583,-2.64583 2.6458334,2.6458334 0 0 1 2.64583,2.64583 2.6458334,2.6458334 0 0 1 -2.64583,2.64583 2.6458334,2.6458334 0 0 1 -2.64583,-2.64583 m 3.96875,0 -1.32292,-1.32292 v 0.79375 h -1.05833 v 1.05834 h 1.05833 v 0.79375 z"
|
||||
id="path1"
|
||||
style="stroke-width:0.264583;fill:#abc837" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.2 KiB |
67
static/images/default_light/ok.svg
Normal file
@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="20.000002"
|
||||
height="20.000013"
|
||||
viewBox="0 0 5.291667 5.2916703"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="ok.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="2.9208594"
|
||||
inkscape:cx="9.7574023"
|
||||
inkscape:cy="10.27095"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="2190"
|
||||
inkscape:window-height="1404"
|
||||
inkscape:window-x="1463"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d6d6d6"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-90.222917,-148.43125)">
|
||||
<title
|
||||
id="title1">check-circle</title>
|
||||
<path
|
||||
d="m 92.86875,148.43125 c -1.455208,0 -2.645833,1.19063 -2.645833,2.64583 0,1.45521 1.190625,2.64584 2.645833,2.64584 1.455209,0 2.645834,-1.19063 2.645834,-2.64584 0,-1.4552 -1.190625,-2.64583 -2.645834,-2.64583 m -0.529166,3.96875 -1.322917,-1.32292 0.373062,-0.37306 0.949855,0.94721 2.008187,-2.00819 0.373063,0.37571 z"
|
||||
id="path1"
|
||||
style="fill:#aad400;stroke-width:0.264583" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.2 KiB |
68
static/images/default_light/warning.svg
Normal file
@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="23.157846"
|
||||
height="20.000013"
|
||||
viewBox="0 0 6.1271801 5.2916704"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="warning.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="22.627417"
|
||||
inkscape:cx="9.3028736"
|
||||
inkscape:cy="12.772116"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="2190"
|
||||
inkscape:window-height="1404"
|
||||
inkscape:window-x="1463"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d6d6d6"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-123.825,-155.04583)">
|
||||
<title
|
||||
id="title1">alert</title>
|
||||
<path
|
||||
d="m 127.21387,158.38793 h -0.65056 v -1.39253 h 0.65056 m 0,2.50657 h -0.65056 v -0.55701 h 0.65056 m -3.38887,1.39254 h 6.12718 l -3.06358,-5.29167 z"
|
||||
id="path1"
|
||||
style="fill:#ff9800;fill-opacity:1;stroke-width:0.278508"
|
||||
sodipodi:nodetypes="cccccccccccc" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.1 KiB |
69
static/images/gruvbox/forward.svg
Normal file
@ -0,0 +1,69 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="19.999975"
|
||||
height="19.999975"
|
||||
viewBox="0 0 5.29166 5.2916603"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="forward.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="22.627417"
|
||||
inkscape:cx="9.3028736"
|
||||
inkscape:cy="12.772116"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="2190"
|
||||
inkscape:window-height="1404"
|
||||
inkscape:window-x="1463"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d6d6d6"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-123.825,-155.04583)">
|
||||
<title
|
||||
id="title1">alert</title>
|
||||
<title
|
||||
id="title1-1">arrow-right-bold-circle</title>
|
||||
<path
|
||||
d="m 123.825,157.69166 a 2.6458334,2.6458334 0 0 1 2.64583,-2.64583 2.6458334,2.6458334 0 0 1 2.64583,2.64583 2.6458334,2.6458334 0 0 1 -2.64583,2.64583 2.6458334,2.6458334 0 0 1 -2.64583,-2.64583 m 3.96875,0 -1.32292,-1.32292 v 0.79375 h -1.05833 v 1.05834 h 1.05833 v 0.79375 z"
|
||||
id="path1"
|
||||
style="stroke-width:0.264583;fill:#abc837" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.2 KiB |
67
static/images/gruvbox/ok.svg
Normal file
@ -0,0 +1,67 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="20.000002"
|
||||
height="20.000013"
|
||||
viewBox="0 0 5.291667 5.2916703"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="ok.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="2.9208594"
|
||||
inkscape:cx="9.7574023"
|
||||
inkscape:cy="10.27095"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="2190"
|
||||
inkscape:window-height="1404"
|
||||
inkscape:window-x="1463"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d6d6d6"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-90.222917,-148.43125)">
|
||||
<title
|
||||
id="title1">check-circle</title>
|
||||
<path
|
||||
d="m 92.86875,148.43125 c -1.455208,0 -2.645833,1.19063 -2.645833,2.64583 0,1.45521 1.190625,2.64584 2.645833,2.64584 1.455209,0 2.645834,-1.19063 2.645834,-2.64584 0,-1.4552 -1.190625,-2.64583 -2.645834,-2.64583 m -0.529166,3.96875 -1.322917,-1.32292 0.373062,-0.37306 0.949855,0.94721 2.008187,-2.00819 0.373063,0.37571 z"
|
||||
id="path1"
|
||||
style="fill:#aad400;stroke-width:0.264583" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.2 KiB |
68
static/images/gruvbox/warning.svg
Normal file
@ -0,0 +1,68 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
|
||||
<svg
|
||||
width="23.157846"
|
||||
height="20.000013"
|
||||
viewBox="0 0 6.1271801 5.2916704"
|
||||
version="1.1"
|
||||
id="svg8"
|
||||
inkscape:version="1.3.2 (1:1.3.2+202311252150+091e20ef0f)"
|
||||
sodipodi:docname="warning.svg"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg"
|
||||
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
|
||||
xmlns:cc="http://creativecommons.org/ns#"
|
||||
xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<defs
|
||||
id="defs2" />
|
||||
<sodipodi:namedview
|
||||
id="base"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pageshadow="2"
|
||||
inkscape:zoom="22.627417"
|
||||
inkscape:cx="9.3028736"
|
||||
inkscape:cy="12.772116"
|
||||
inkscape:document-units="px"
|
||||
inkscape:current-layer="layer1"
|
||||
showgrid="false"
|
||||
units="px"
|
||||
inkscape:window-width="2190"
|
||||
inkscape:window-height="1404"
|
||||
inkscape:window-x="1463"
|
||||
inkscape:window-y="16"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:showpageshadow="true"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d6d6d6"
|
||||
showborder="true" />
|
||||
<metadata
|
||||
id="metadata5">
|
||||
<rdf:RDF>
|
||||
<cc:Work
|
||||
rdf:about="">
|
||||
<dc:format>image/svg+xml</dc:format>
|
||||
<dc:type
|
||||
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
|
||||
</cc:Work>
|
||||
</rdf:RDF>
|
||||
</metadata>
|
||||
<g
|
||||
inkscape:label="Layer 1"
|
||||
inkscape:groupmode="layer"
|
||||
id="layer1"
|
||||
transform="translate(-123.825,-155.04583)">
|
||||
<title
|
||||
id="title1">alert</title>
|
||||
<path
|
||||
d="m 127.21387,158.38793 h -0.65056 v -1.39253 h 0.65056 m 0,2.50657 h -0.65056 v -0.55701 h 0.65056 m -3.38887,1.39254 h 6.12718 l -3.06358,-5.29167 z"
|
||||
id="path1"
|
||||
style="fill:#ff9800;fill-opacity:1;stroke-width:0.278508"
|
||||
sodipodi:nodetypes="cccccccccccc" />
|
||||
</g>
|
||||
</svg>
|
After Width: | Height: | Size: 2.1 KiB |
@ -1,5 +1,6 @@
|
||||
export class UI {
|
||||
constructor() {
|
||||
constructor(datapointData) {
|
||||
this.datapoint = datapointData
|
||||
document.addEventListener('keydown', evt=>this.keyHandler(evt))
|
||||
document.querySelector('input[name="group"]').focus()
|
||||
}
|
||||
|
@ -1,13 +1,7 @@
|
||||
function preset(hours) {
|
||||
const inputPreset = document.querySelector('input[name="preset"]')
|
||||
inputPreset.value = hours
|
||||
inputPreset.form.submit()
|
||||
}
|
||||
|
||||
function offsetTime(seconds) {
|
||||
const inputPreset = document.querySelector('input[name="offset-time"]')
|
||||
inputPreset.value = seconds
|
||||
inputPreset.form.submit()
|
||||
function selectDisplay(display) {
|
||||
const inputDisplay = document.getElementById('input-display')
|
||||
inputDisplay.value = display
|
||||
inputDisplay.form.submit()
|
||||
}
|
||||
|
||||
class Graph {
|
||||
@ -61,7 +55,9 @@ class Dataset {
|
||||
constructor(id, initialData) {
|
||||
this.datapointID = id
|
||||
this.values = {}
|
||||
initialData.forEach(v=>this.values[v.ID] = v)
|
||||
if (initialData === null)
|
||||
return
|
||||
initialData.forEach(v => this.values[v.ID] = v)
|
||||
}
|
||||
|
||||
xValues() {
|
||||
@ -76,7 +72,7 @@ class Dataset {
|
||||
return fetch(`/datapoint/json/${this.datapointID}?f=${from}&t=${to}`)
|
||||
.then(data => data.json())
|
||||
.then(datapointValues => {
|
||||
datapointValues.forEach(dp=>{
|
||||
datapointValues.forEach(dp => {
|
||||
this.values[dp.ID] = dp
|
||||
})
|
||||
document.getElementById('num-values').innerText = Object.keys(this.values).length
|
||||
|
@ -6,6 +6,7 @@ export class UI {
|
||||
const list = document.getElementById('acknowledged-list')
|
||||
list.classList.remove('hidden')
|
||||
}
|
||||
this.acknowledgeDisplay(showAcked == 'true')
|
||||
|
||||
const display = localStorage.getItem('problems_display')
|
||||
if (display === null)
|
||||
@ -23,17 +24,32 @@ export class UI {
|
||||
}
|
||||
|
||||
toggleAcknowledged(evt) {
|
||||
const list = document.getElementById('acknowledged-list')
|
||||
this.acknowledgeDisplay(evt.target.checked)
|
||||
}
|
||||
|
||||
if (evt.target.checked) {
|
||||
acknowledgeDisplay(show) {
|
||||
const list = document.getElementById('acknowledged-list')
|
||||
const areaItems = document.querySelectorAll('.acked')
|
||||
|
||||
if (show) {
|
||||
list.classList.remove('hidden')
|
||||
areaItems.forEach(item=>item.classList.remove('hidden'))
|
||||
localStorage.setItem('show_acknowledged', true)
|
||||
} else {
|
||||
list.classList.add('hidden')
|
||||
areaItems.forEach(item=>item.classList.add('hidden'))
|
||||
localStorage.setItem('show_acknowledged', false)
|
||||
}
|
||||
}
|
||||
|
||||
selectCurrent() {
|
||||
location.href = '/problems?selection=current'
|
||||
}
|
||||
|
||||
selectAll() {
|
||||
location.href = '/problems?selection=all'
|
||||
}
|
||||
|
||||
displayList() {
|
||||
document.querySelector('.display-list').classList.remove('hidden')
|
||||
document.querySelector('.display-areas').classList.add('hidden')
|
||||
|
@ -19,9 +19,10 @@ export class UI {
|
||||
let html = Object.keys(this.trigger.datapoints).sort().map(dpName => {
|
||||
const dp = this.trigger.datapoints[dpName]
|
||||
return `
|
||||
<div class="datapoint delete"><a href="#" onclick="_ui.deleteDatapoint('${dp.Name}')"><img src="/images/${this.version}/${this.theme}/delete.svg"></a></div>
|
||||
<div class="datapoint name ${dp.Found ? 'valid' : 'invalid'}"><b>${dp.Name}</b></div>
|
||||
<div class="datapoint value">${dp.Found ? dp.LastDatapointValue.TemplateValue : ''}</div>
|
||||
<div class="daatpoint values"><a href="/datapoint/values/${dp.ID}"><img src="/images/${this.version}/${this.theme}/values.svg"></a></div>
|
||||
<div class="datapoint delete"><a href="#" onclick="_ui.deleteDatapoint('${dp.Name}')"><img src="/images/${this.version}/${this.theme}/delete.svg"></a></div>
|
||||
`
|
||||
}).join('')
|
||||
datapoints.innerHTML += html
|
||||
@ -84,7 +85,7 @@ export class UI {
|
||||
})
|
||||
}//}}}
|
||||
deleteDatapoint(name) {//{{{
|
||||
if (!confirm(`Delete ${name}?`)) {
|
||||
if (!confirm(`Remove datapoint ${name} from this trigger?`)) {
|
||||
return
|
||||
}
|
||||
|
||||
@ -158,7 +159,7 @@ export class Trigger {
|
||||
alert(json.Error)
|
||||
return
|
||||
}
|
||||
this.datapoints[dp.Name] = dp
|
||||
this.datapoints[dp.Name] = json.Datapoint
|
||||
})
|
||||
}//}}}
|
||||
}
|
||||
|
@ -1,5 +1,9 @@
|
||||
@import "theme-@{THEME}.less";
|
||||
|
||||
#datapoints-filter.invalid-regex {
|
||||
background-color: #ffd5d5;
|
||||
}
|
||||
|
||||
#datapoints {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, min-content);
|
||||
@ -30,6 +34,10 @@
|
||||
color: @text3;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
div {
|
||||
white-space: nowrap;
|
||||
align-self: center;
|
||||
@ -113,33 +121,6 @@
|
||||
margin-top: 16px;
|
||||
|
||||
#graph-values {
|
||||
height: calc(100vh - 308px);
|
||||
}
|
||||
}
|
||||
|
||||
.time-offset {
|
||||
display: grid;
|
||||
grid-template-columns: min-content repeat(3, min-content);
|
||||
grid-gap: 16px;
|
||||
margin-top: 16px;
|
||||
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
|
||||
.header-1 {
|
||||
font-weight: bold;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.header-2 {
|
||||
font-weight: bold;
|
||||
justify-self: start;
|
||||
grid-column: ~"2 / -1";
|
||||
}
|
||||
|
||||
.preset {
|
||||
white-space: nowrap;
|
||||
justify-self: start;
|
||||
padding-right: 32px;
|
||||
height: calc(100vh - 200px);
|
||||
}
|
||||
}
|
||||
|
@ -62,7 +62,7 @@ dialog {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
dialog, #datapoints, #problems-list, #acknowledged-list, #values, #services, #notifications {
|
||||
dialog, #datapoints, #problems-list, #acknowledged-list, #values, #services, #notifications, #group, .table {
|
||||
background-color: #fff !important;
|
||||
border: 1px solid #ddd;
|
||||
box-shadow: 5px 5px 8px 0px rgba(0,0,0,0.25);
|
||||
|
@ -20,3 +20,13 @@ body {
|
||||
background-color: #a00;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
span.error {
|
||||
color: #f66;
|
||||
}
|
||||
|
||||
input[type="datetime-local"] {
|
||||
background-color: @bg2;
|
||||
color: #ccc;
|
||||
border: 1px solid #535353;
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
@import "theme-@{THEME}.less";
|
||||
@import "table.less";
|
||||
|
||||
html {
|
||||
box-sizing: border-box;
|
||||
@ -266,6 +267,14 @@ span.seconds {
|
||||
display: none;
|
||||
}
|
||||
|
||||
span.ok {
|
||||
color: #0a0;
|
||||
}
|
||||
|
||||
span.error {
|
||||
color: #a00;
|
||||
}
|
||||
|
||||
label {
|
||||
user-select: none;
|
||||
}
|
||||
@ -280,3 +289,44 @@ label {
|
||||
width: min-content;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
#time-selector {
|
||||
position: absolute;
|
||||
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 8px repeat(2,min-content) 8px min-content 8px 1px 8px repeat(3,min-content) 8px repeat(3,min-content) 8px 1px 8px repeat(2,min-content) 8px;
|
||||
grid-gap: 6px 8px;
|
||||
align-items: center;
|
||||
|
||||
width: min-content;
|
||||
background-color: @bg1;
|
||||
border: 1px solid @bg3;
|
||||
border-radius: 6px;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.vertical-line {
|
||||
background-color: @bg3;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding-top: 12px;
|
||||
font-weight: bold;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100px;
|
||||
margin-top: 12px;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
@ -1,56 +1,10 @@
|
||||
@import "theme-@{THEME}.less";
|
||||
|
||||
#time-select {
|
||||
display: grid;
|
||||
grid-template-columns: min-content min-content;
|
||||
grid-gap: 6px 16px;
|
||||
|
||||
width: min-content;
|
||||
border-radius: 6px;
|
||||
|
||||
button {
|
||||
width: 100px;
|
||||
margin-top: 12px;
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
#time-offsets {
|
||||
display: grid;
|
||||
grid-template-columns: min-content repeat(3, min-content);
|
||||
grid-gap: 16px;
|
||||
margin-top: 16px;
|
||||
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
|
||||
.header-1 {
|
||||
font-weight: bold;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.header-2 {
|
||||
font-weight: bold;
|
||||
justify-self: start;
|
||||
grid-column: ~"2 / -1";
|
||||
}
|
||||
|
||||
.preset {
|
||||
white-space: nowrap;
|
||||
justify-self: start;
|
||||
padding-right: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
input[type="datetime-local"] {
|
||||
padding: 6px;
|
||||
}
|
||||
|
||||
#notifications {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, min-content);
|
||||
grid-gap: 4px 16px;
|
||||
margin-top: 32px;
|
||||
margin-top: 96px;
|
||||
margin-bottom: 32px;
|
||||
background-color: @bg3;
|
||||
padding: 16px 24px;
|
||||
@ -67,12 +21,4 @@ input[type="datetime-local"] {
|
||||
font-weight: @bold;
|
||||
color: @text3;
|
||||
}
|
||||
|
||||
.ok {
|
||||
color: #0a0;
|
||||
}
|
||||
|
||||
.error {
|
||||
color: #a00;
|
||||
}
|
||||
}
|
||||
|
@ -1,36 +1,24 @@
|
||||
@import "theme-@{THEME}.less";
|
||||
|
||||
#problems-list, #acknowledged-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, min-content);
|
||||
grid-gap: 4px 16px;
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
background-color: @bg3;
|
||||
padding: 16px 24px;
|
||||
width: min-content;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
|
||||
div {
|
||||
white-space: nowrap;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-weight: @bold;
|
||||
color: @text3;
|
||||
}
|
||||
grid-template-columns: repeat(7, min-content);
|
||||
|
||||
.trigger {
|
||||
color: @color1;
|
||||
font-weight: @bold;
|
||||
}
|
||||
|
||||
.acknowledge {
|
||||
img {
|
||||
height: 16px;
|
||||
}
|
||||
img.acknowledge {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.info {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.icons {
|
||||
display: grid;
|
||||
grid-template-columns: min-content min-content;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,28 +26,23 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
#areas {
|
||||
#area-grouped {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 24px;
|
||||
margin-top: 16px;
|
||||
align-items: flex-start;
|
||||
|
||||
.area {
|
||||
grid-template-columns: repeat(5, min-content);
|
||||
|
||||
.section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, min-content);
|
||||
grid-gap: 8px 12px;
|
||||
|
||||
.name {
|
||||
color: @text2;
|
||||
grid-column: ~"1 / -1";
|
||||
font-weight: bold !important;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
padding: 4px 10px;
|
||||
border-radius: 5px;
|
||||
background: @bg3;
|
||||
color: #fff;
|
||||
width: min-content;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
31
static/less/table.less
Normal file
@ -0,0 +1,31 @@
|
||||
@import "theme-@{THEME}.less";
|
||||
|
||||
.table {
|
||||
display: grid;
|
||||
grid-gap: 6px 16px;
|
||||
align-items: center;
|
||||
|
||||
.row {
|
||||
grid-column: ~"1 / -1";
|
||||
}
|
||||
|
||||
margin-top: 32px;
|
||||
margin-bottom: 32px;
|
||||
background-color: @bg3;
|
||||
padding: 16px 24px;
|
||||
width: min-content;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
|
||||
& > div {
|
||||
white-space: nowrap;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: 0.85em;
|
||||
font-weight: bold;
|
||||
color: @text3;
|
||||
line-height: unset !important;
|
||||
}
|
||||
}
|
@ -19,11 +19,15 @@
|
||||
.datapoints {
|
||||
font: "Roboto Mono", monospace;
|
||||
display: grid;
|
||||
grid-template-columns: min-content min-content 1fr;
|
||||
grid-template-columns: repeat(4, min-content);
|
||||
gap: 6px 8px;
|
||||
margin-bottom: 8px;
|
||||
white-space: nowrap;
|
||||
|
||||
div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
color: #c83737;
|
||||
}
|
||||
@ -31,6 +35,11 @@
|
||||
.delete img {
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.values img {
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.action {
|
||||
|
41
timefilter.go
Normal file
@ -0,0 +1,41 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
// External
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
|
||||
// Standard
|
||||
"time"
|
||||
)
|
||||
|
||||
func timefilterParse(timeFromStr, timeToStr, offset, preset string) (timeFrom, timeTo time.Time, err error) {
|
||||
if preset != "" {
|
||||
presetTimeInterval(time.Hour, preset, &timeFrom, &timeTo)
|
||||
return
|
||||
}
|
||||
|
||||
yesterday := time.Now().Add(time.Duration(-24 * time.Hour))
|
||||
timeFrom, err = parseHTMLDateTime(timeFromStr, yesterday)
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithData(timeFromStr)
|
||||
return
|
||||
}
|
||||
|
||||
timeTo, err = parseHTMLDateTime(timeToStr, time.Now())
|
||||
if err != nil {
|
||||
err = werr.Wrap(err).WithData(timeToStr)
|
||||
return
|
||||
}
|
||||
|
||||
// Protect the user from switching from/to dates, leading to
|
||||
// zero matching values from the database.
|
||||
if timeFrom.After(timeTo) {
|
||||
timeFrom, timeTo = timeTo, timeFrom
|
||||
}
|
||||
|
||||
// Apply an optionally set offset (in seconds).
|
||||
timeFrom = applyTimeOffset(timeFrom, time.Second, offset)
|
||||
timeTo = applyTimeOffset(timeTo, time.Second, offset)
|
||||
|
||||
return
|
||||
}
|
57
trigger.go
@ -4,6 +4,8 @@ import (
|
||||
// External
|
||||
werr "git.gibonuddevalla.se/go/wrappederror"
|
||||
"github.com/expr-lang/expr"
|
||||
"github.com/expr-lang/expr/ast"
|
||||
"github.com/expr-lang/expr/parser"
|
||||
"github.com/lib/pq"
|
||||
|
||||
// Standard
|
||||
@ -14,11 +16,23 @@ import (
|
||||
)
|
||||
|
||||
type Trigger struct {
|
||||
ID int
|
||||
Name string
|
||||
SectionID int `db:"section_id"`
|
||||
Expression string
|
||||
Datapoints []string
|
||||
ID int
|
||||
Name string
|
||||
SectionID int `db:"section_id"`
|
||||
Expression string
|
||||
Datapoints []string
|
||||
DatapointValues map[string]any
|
||||
}
|
||||
|
||||
type ExprRenamePatcher struct {
|
||||
OldName string
|
||||
NewName string
|
||||
}
|
||||
|
||||
func (p ExprRenamePatcher) Visit(node *ast.Node) {
|
||||
if n, ok := (*node).(*ast.IdentifierNode); ok && n.Value == p.OldName {
|
||||
ast.Patch(node, &ast.IdentifierNode{Value: p.NewName})
|
||||
}
|
||||
}
|
||||
|
||||
func TriggerCreate(sectionID int, name string) (t Trigger, err error) { // {{{
|
||||
@ -126,6 +140,14 @@ func TriggerRetrieve(id int) (trigger Trigger, err error) { // {{{
|
||||
err = json.Unmarshal(jsonData, &trigger)
|
||||
return
|
||||
} // }}}
|
||||
func TriggerDelete(id int) (err error) { // {{{
|
||||
_, err = service.Db.Conn.Exec(`DELETE FROM public.trigger WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return werr.Wrap(err).WithData(id)
|
||||
}
|
||||
return
|
||||
} // }}}
|
||||
|
||||
func (t *Trigger) Validate() (ok bool, err error) { // {{{
|
||||
if strings.TrimSpace(t.Name) == "" {
|
||||
err = fmt.Errorf("Name can't be empty")
|
||||
@ -211,14 +233,6 @@ func (t *Trigger) Update() (err error) { // {{{
|
||||
}
|
||||
return
|
||||
} // }}}
|
||||
func TriggerDelete(id int) (err error) { // {{{
|
||||
_, err = service.Db.Conn.Exec(`DELETE FROM public.trigger WHERE id=$1`, id)
|
||||
if err != nil {
|
||||
return werr.Wrap(err).WithData(id)
|
||||
}
|
||||
return
|
||||
} // }}}
|
||||
|
||||
func (t *Trigger) Run() (output any, err error) { // {{{
|
||||
datapoints := make(map[string]Datapoint)
|
||||
for _, dpname := range t.Datapoints {
|
||||
@ -231,9 +245,9 @@ func (t *Trigger) Run() (output any, err error) { // {{{
|
||||
datapoints[dpname] = dp
|
||||
}
|
||||
|
||||
env := make(map[string]any)
|
||||
t.DatapointValues = make(map[string]any)
|
||||
for dpName, dp := range datapoints {
|
||||
env[dpName] = dp.LastDatapointValue.Value()
|
||||
t.DatapointValues[dpName] = dp.LastDatapointValue.Value()
|
||||
}
|
||||
|
||||
program, err := expr.Compile(t.Expression)
|
||||
@ -241,9 +255,20 @@ func (t *Trigger) Run() (output any, err error) { // {{{
|
||||
return
|
||||
}
|
||||
|
||||
output, err = expr.Run(program, env)
|
||||
output, err = expr.Run(program, t.DatapointValues)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
return
|
||||
} // }}}
|
||||
func (t *Trigger) RenameDatapoint(from, to string) error { // {{{
|
||||
tree, err := parser.Parse(t.Expression)
|
||||
if err != nil {
|
||||
return werr.Wrap(err).WithData(t.Expression)
|
||||
}
|
||||
|
||||
ast.Walk(&tree.Node, ExprRenamePatcher{from, to})
|
||||
t.Expression = tree.Node.String()
|
||||
|
||||
return nil
|
||||
} // }}}
|
||||
|
118
views/components/timefilter.gotmpl
Normal file
@ -0,0 +1,118 @@
|
||||
{{ define "timefilter" }}
|
||||
<script type="text/javascript">
|
||||
function preset(hours) {
|
||||
const inputPreset = document.querySelector('input[name="time-preset"]')
|
||||
inputPreset.value = hours
|
||||
inputPreset.form.submit()
|
||||
}
|
||||
|
||||
function offsetTime(seconds) {
|
||||
const el = document.querySelector('input[name="time-offset"]')
|
||||
el.value = seconds
|
||||
el.form.submit()
|
||||
}
|
||||
|
||||
function enterHandler(evt) {
|
||||
if (evt.key == 'Enter')
|
||||
document.getElementById('form-time-selector').submit()
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
<form action="{{ .Data.TimeSubmit }}" method="get" id="form-time-selector" class="{{ if .Data.TimeHidden }}hidden{{ end }}">
|
||||
<input type="hidden" name="time-preset" value="">
|
||||
<input type="hidden" name="time-offset" value=0>
|
||||
|
||||
<div id="time-selector">
|
||||
{{/* ====== Row 1 ====== */}}
|
||||
<div></div>
|
||||
<div class="header" style="grid-column: 2 / 6">Date and time</div>
|
||||
|
||||
<div></div>
|
||||
<div class="vertical-line" style="grid-column: 7; grid-row: 1 / 5; height: 100%"> </div>
|
||||
<div></div>
|
||||
|
||||
<div class="header" style="grid-column: 9 / 16">Offsets</div>
|
||||
|
||||
<div></div>
|
||||
<div class="vertical-line" style="grid-column: 17; grid-row: 1 / 5; height: 100%"></div>
|
||||
<div></div>
|
||||
|
||||
<div class="header" style="grid-column: 19 / 21">Presets</div>
|
||||
<div></div>
|
||||
|
||||
|
||||
|
||||
|
||||
{{/* ====== Row 2 ====== */}}
|
||||
<div></div>
|
||||
<div>From</div>
|
||||
<input name="time-f" value="{{ .Data.TimeFrom }}" type="datetime-local" onkeydown="enterHandler(event)">
|
||||
<div></div>
|
||||
<div></div>
|
||||
|
||||
<div></div>
|
||||
{{/* Vertical line */}}
|
||||
<div></div>
|
||||
|
||||
<div><a href="#" onclick="offsetTime(-3600)">◀</a></div>
|
||||
<div>Hour</div>
|
||||
<div><a href="#" onclick="offsetTime(3600)">▶</a></div>
|
||||
|
||||
<div></div>
|
||||
|
||||
<div><a href="#" onclick="offsetTime(-7 * 86400)">◀</a></div>
|
||||
<div>Week</div>
|
||||
<div><a href="#" onclick="offsetTime(7 * 86400)">▶</a></div>
|
||||
|
||||
<div></div>
|
||||
{{/* Vertical line */}}
|
||||
<div></div>
|
||||
|
||||
<div class="preset">⚫︎ <a href="#" onclick="preset(1)">Last hour</a></div>
|
||||
<div class="preset">⚫︎ <a href="#" onclick="preset(24 * 7)">Last 7 days</a></div>
|
||||
<div></div>
|
||||
|
||||
|
||||
|
||||
|
||||
{{/* ====== Row 3 ====== */}}
|
||||
<div></div>
|
||||
<div>To</div>
|
||||
<input name="time-t" value="{{ .Data.TimeTo }}" type="datetime-local" onkeydown="enterHandler(event)">
|
||||
<div><img src="/images/{{ .VERSION }}/{{ .CONFIG.THEME }}/forward.svg" onclick="document.getElementById('form-time-selector').submit()"></div>
|
||||
<div></div>
|
||||
|
||||
<div></div>
|
||||
{{/* Vertical line */}}
|
||||
<div></div>
|
||||
|
||||
<div><a href="#" onclick="offsetTime(-86400)">◀</a></div>
|
||||
<div>Day</div>
|
||||
<div><a href="#" onclick="offsetTime(86400)">▶</a></div>
|
||||
|
||||
<div></div>
|
||||
|
||||
<div><a href="#" onclick="offsetTime(-31 * 86400)">◀</a></div>
|
||||
<div>Month</div>
|
||||
<div><a href="#" onclick="offsetTime(31 * 86400)">▶</a></div>
|
||||
|
||||
<div></div>
|
||||
{{/* Vertical line */}}
|
||||
<div></div>
|
||||
|
||||
<div class="preset">⚫︎ <a href="#" onclick="preset(24)">Last 24 hours</a></div>
|
||||
<div class="preset">⚫︎ <a href="#" onclick="preset(24 * 31)">Last 31 days</a></div>
|
||||
|
||||
<div></div>
|
||||
|
||||
|
||||
|
||||
|
||||
{{/* ====== Row 4 ====== */}}
|
||||
<div style="grid-column: 1 / 5; height: 8px"></div>
|
||||
<div style="grid-column: 8 / 17; height: 8px"></div>
|
||||
<div style="grid-column: 18 / 22; height: 8px"></div>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
@ -3,48 +3,59 @@
|
||||
|
||||
<script type="module" defer>
|
||||
import {UI} from "/js/{{ .VERSION }}/datapoint_edit.mjs"
|
||||
window._ui = new UI()
|
||||
window._ui = new UI({{ .Data.Datapoint }})
|
||||
</script>
|
||||
|
||||
{{ block "page_label" . }}{{end}}
|
||||
|
||||
<form id="form-trigger" action="/datapoint/update/{{ .Data.Datapoint.ID }}" method="post">
|
||||
<div id="widgets" class="widgets">
|
||||
<div class="label">Group</div>
|
||||
<div><input type="text" name="group" value="{{ .Data.Datapoint.Group }}"></div>
|
||||
<div id="widgets" class="widgets">
|
||||
<div class="label">Group</div>
|
||||
<div><input type="text" name="group" value="{{ .Data.Datapoint.Group }}"></div>
|
||||
|
||||
<div class="label">Name</div>
|
||||
<div><input type="text" name="name" value="{{ .Data.Datapoint.Name }}"></div>
|
||||
|
||||
<div class="label">Datatype</div>
|
||||
<div>
|
||||
<select name="datatype">
|
||||
<option {{ if eq .Data.Datapoint.Datatype "INT" }}selected{{end}}>INT</option>
|
||||
<option {{ if eq .Data.Datapoint.Datatype "STRING" }}selected{{end}}>STRING</option>
|
||||
<option {{ if eq .Data.Datapoint.Datatype "DATETIME" }}selected{{end}}>DATETIME</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="label">Name</div>
|
||||
<div><input type="text" name="name" value="{{ .Data.Datapoint.Name }}"></div>
|
||||
|
||||
<div class="label">No data<br>problem time<br>(seconds)</div>
|
||||
<div>
|
||||
<input type="text" name="nodata_seconds" value="{{ .Data.Datapoint.NodataProblemSeconds }}">
|
||||
<div class="description">A problem is raised and notified if an entry isn't made within this time.</div>
|
||||
<div class="description">Set to 0 to disable.</div>
|
||||
</div>
|
||||
<div class="label">Datatype</div>
|
||||
<div>
|
||||
<select name="datatype">
|
||||
<option {{ if eq .Data.Datapoint.Datatype "INT" }}selected{{end}}>INT</option>
|
||||
<option {{ if eq .Data.Datapoint.Datatype "STRING" }}selected{{end}}>STRING</option>
|
||||
<option {{ if eq .Data.Datapoint.Datatype "DATETIME" }}selected{{end}}>DATETIME</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="label">Comment</div>
|
||||
<div>
|
||||
<textarea name="comment" rows=4>{{ .Data.Datapoint.Comment }}</textarea>
|
||||
</div>
|
||||
<div class="label">No data<br>problem time<br>(seconds)</div>
|
||||
<div>
|
||||
<input type="text" name="nodata_seconds" value="{{ .Data.Datapoint.NodataProblemSeconds }}">
|
||||
<div class="description">A problem is raised and notified if an entry isn't made within this time.</div>
|
||||
<div class="description">Set to 0 to disable.</div>
|
||||
</div>
|
||||
|
||||
<div class="label">Comment</div>
|
||||
<div>
|
||||
<textarea name="comment" rows=4>{{ .Data.Datapoint.Comment }}</textarea>
|
||||
</div>
|
||||
|
||||
|
||||
<div></div>
|
||||
<div class="action">
|
||||
{{ if eq .Data.Datapoint.ID 0 }}
|
||||
<button id="button-update">Create</button>
|
||||
{{ else }}
|
||||
<button id="button-update">Update</button>
|
||||
{{ end }}
|
||||
<div></div>
|
||||
<div class="action">
|
||||
{{ if eq .Data.Datapoint.ID 0 }}
|
||||
<button id="button-update">Create</button>
|
||||
{{ else }}
|
||||
<button id="button-update">Update</button>
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<div></div>
|
||||
<div style="margin-top: 32px">
|
||||
<b>Used in the following triggers:</b>
|
||||
<ul>
|
||||
{{ range .Data.Triggers }}
|
||||
<li><a href="/trigger/edit/{{ .ID }}">{{ .Name }}</a></li>
|
||||
{{ end }}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
|
@ -7,56 +7,24 @@
|
||||
|
||||
{{ block "page_label" . }}{{end}}
|
||||
|
||||
<form action="/datapoint/values/{{ .Data.Datapoint.ID }}" method="get" style="margin-top: -16px">
|
||||
<input type="hidden" name="preset" value="">
|
||||
<input type="hidden" name="offset-time" value=0>
|
||||
{{ block "timefilter" . }}{{ end }}
|
||||
|
||||
{{ 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 }}
|
||||
{{ if eq .Data.Datapoint.Datatype "INT" }}
|
||||
<div style="margin-top: 16px">
|
||||
<input onchange="selectDisplay('graph')" name="display" type="radio" id="display-graph" {{ if $graph }} checked {{ end}}> <label for="display-graph">Graph</label>
|
||||
<br>
|
||||
<input onchange="selectDisplay('list')" name="display" 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 class="header-1">Presets</div>
|
||||
<div class="header-2">Offsets</div>
|
||||
|
||||
<div class="preset"><a href="#" onclick="preset(1)">Last hour</a></div>
|
||||
|
||||
<div><a href="#" onclick="offsetTime(-3600)">◀</a></div>
|
||||
<div>Hour</div>
|
||||
<div><a href="#" onclick="offsetTime(3600)">▶</a></div>
|
||||
|
||||
<div class="preset"><a href="#" onclick="preset(24)">Last 24 hours</a></div>
|
||||
|
||||
<div><a href="#" onclick="offsetTime(-86400)">◀</a></div>
|
||||
<div>Day</div>
|
||||
<div><a href="#" onclick="offsetTime(86400)">▶</a></div>
|
||||
|
||||
<div class="preset"><a href="#" onclick="preset(24 * 7)">Last 7 days</a></div>
|
||||
|
||||
<div><a href="#" onclick="offsetTime(-7 * 86400)">◀</a></div>
|
||||
<div>Week</div>
|
||||
<div><a href="#" onclick="offsetTime(7 * 86400)">▶</a></div>
|
||||
|
||||
<div class="preset"><a href="#" onclick="preset(24 * 31)">Last 31 days</a></div>
|
||||
|
||||
<div><a href="#" onclick="offsetTime(-31 * 86400)">◀</a></div>
|
||||
<div>Month</div>
|
||||
<div><a href="#" onclick="offsetTime(31 * 86400)">▶</a></div>
|
||||
</div>
|
||||
<button>OK</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
<script type="text/javascript">
|
||||
const inputDisplay = document.createElement('input')
|
||||
inputDisplay.id = 'input-display'
|
||||
inputDisplay.type = 'hidden'
|
||||
inputDisplay.name = 'display'
|
||||
inputDisplay.value = '{{ if $graph }}graph{{ else }}list{{ end }}'
|
||||
document.getElementById('form-time-selector').append(inputDisplay)
|
||||
</script>
|
||||
|
||||
{{ if $graph }}
|
||||
<div class="graph">
|
||||
@ -72,7 +40,6 @@
|
||||
{{ .Data.Datapoint.ID }},
|
||||
{{ .Data.Values }},
|
||||
)
|
||||
|
||||
</script>
|
||||
{{ else }}
|
||||
<div id="values">
|
||||
|
@ -2,11 +2,96 @@
|
||||
{{ $version := .VERSION }}
|
||||
{{ $theme := .CONFIG.THEME }}
|
||||
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/{{ .CONFIG.THEME }}/datapoints.css">
|
||||
<script type="text/javascript" defer>
|
||||
|
||||
function validateRegex(rxp) {
|
||||
try {
|
||||
''.match(rxp)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function showDatapoints(id) {
|
||||
if (!id)
|
||||
document
|
||||
.querySelectorAll(`[x-datapoint-id]`)
|
||||
.forEach(matchedDP=>matchedDP.classList.remove('hidden'))
|
||||
else
|
||||
document
|
||||
.querySelectorAll(`[x-datapoint-id="${id}"]`)
|
||||
.forEach(matchedDP=>matchedDP.classList.remove('hidden'))
|
||||
}
|
||||
|
||||
function hideDatapoints(id) {
|
||||
if (!id)
|
||||
document.querySelectorAll(`[x-datapoint-id]`).forEach(matchedDP=>
|
||||
matchedDP.classList.add('hidden')
|
||||
)
|
||||
else
|
||||
document.querySelectorAll(`[x-datapoint-id="${id}"]`).forEach(matchedDP=>
|
||||
matchedDP.classList.add('hidden')
|
||||
)
|
||||
}
|
||||
|
||||
function updateRegexValidatedStatus(validated) {
|
||||
const inputFilter = document.getElementById('datapoints-filter')
|
||||
if (validated)
|
||||
inputFilter.classList.remove('invalid-regex')
|
||||
else
|
||||
inputFilter.classList.add('invalid-regex')
|
||||
}
|
||||
|
||||
function filterDatapoints(inputFilter) {
|
||||
const filter = inputFilter.value.toLowerCase()
|
||||
const datapoints = document.querySelectorAll('#datapoints .name')
|
||||
var datapointID
|
||||
|
||||
// Shortcut to show everything if a filter is not given.
|
||||
if (filter == '') {
|
||||
showDatapoints()
|
||||
return
|
||||
}
|
||||
|
||||
// Show nothing if the regex is invalid and can't matching anything.
|
||||
if (!validateRegex(filter)) {
|
||||
hideDatapoints()
|
||||
updateRegexValidatedStatus(false)
|
||||
return
|
||||
} else
|
||||
updateRegexValidatedStatus(true)
|
||||
|
||||
datapoints.forEach(dp=>{
|
||||
const dpName = dp.getAttribute('x-datapoint-name')
|
||||
datapointID = dp.getAttribute('x-datapoint-id')
|
||||
if (dpName.toLowerCase().match(filter))
|
||||
showDatapoints(datapointID)
|
||||
else
|
||||
hideDatapoints(datapointID)
|
||||
})
|
||||
}
|
||||
|
||||
function keyhandler(evt) {
|
||||
if (evt.altKey && evt.shiftKey && evt.key == 'F') {
|
||||
document.getElementById('datapoints-filter').focus()
|
||||
evt.preventDefault()
|
||||
evt.stopPropagation()
|
||||
}
|
||||
}
|
||||
|
||||
// Add the keymap for the keybindings
|
||||
document.addEventListener('keydown', keyhandler)
|
||||
</script>
|
||||
|
||||
{{ block "page_label" . }}{{end}}
|
||||
|
||||
<a href="/datapoint/edit/0">Create</a>
|
||||
|
||||
<div style="margin-top: 16px">
|
||||
<input id="datapoints-filter" type="text" placeholder="Filter (regexp)" style="width: 320px;" oninput="filterDatapoints(this)">
|
||||
</div>
|
||||
|
||||
<div id="datapoints">
|
||||
{{ $prevGroup := "15ecfcc0-b1aa-45cd-af9c-74146a7e7f56-not-very-likely" }}
|
||||
{{ range .Data.Datapoints }}
|
||||
@ -20,18 +105,18 @@
|
||||
<div class="header"></div>
|
||||
|
||||
{{ else }}
|
||||
<div class="line"></div>
|
||||
<div x-datapoint-id="{{ .ID }}" class="line"></div>
|
||||
{{ end }}
|
||||
<div class="name"><a href="/datapoint/edit/{{ .ID }}">{{ .Name }}</a></div>
|
||||
<div class="datatype">{{ .Datatype }}</div>
|
||||
<div class="datatype">{{ .NodataProblemSeconds }}</div>
|
||||
<div class="last-value">{{ format_time .LastValue }}</div>
|
||||
<div x-datapoint-id="{{ .ID }}" x-datapoint-name="{{ .Name }}" class="name"><a href="/datapoint/edit/{{ .ID }}">{{ .Name }}</a></div>
|
||||
<div x-datapoint-id="{{ .ID }}" class="datatype">{{ .Datatype }}</div>
|
||||
<div x-datapoint-id="{{ .ID }}" class="datatype">{{ .NodataProblemSeconds }}</div>
|
||||
<div x-datapoint-id="{{ .ID }}" class="last-value">{{ format_time .LastValue }}</div>
|
||||
{{ if eq .Datatype "DATETIME" }}
|
||||
<div class="value">{{ if .LastDatapointValue.ValueDateTime.Valid }}{{ format_time .LastDatapointValue.Value }}{{ end }}</div>
|
||||
<div x-datapoint-id="{{ .ID }}" class="value">{{ if .LastDatapointValue.ValueDateTime.Valid }}{{ format_time .LastDatapointValue.Value }}{{ end }}</div>
|
||||
{{ else }}
|
||||
<div class="value">{{ .LastDatapointValue.Value }}</div>
|
||||
<div x-datapoint-id="{{ .ID }}" class="value">{{ .LastDatapointValue.Value }}</div>
|
||||
{{ end }}
|
||||
<div class="icons">
|
||||
<div x-datapoint-id="{{ .ID }}" class="icons">
|
||||
{{ if eq .Comment "" }}
|
||||
<div class="values"><img class="info" src="/images/{{ $version }}/{{ $theme }}/info-outline.svg"></div>
|
||||
{{ else }}
|
||||
|
47
views/pages/notification/pushover.gotmpl
Normal file
@ -0,0 +1,47 @@
|
||||
{{ define "page" }}
|
||||
<h1>Pushover</h1>
|
||||
<style>
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: min-content 1fr;
|
||||
grid-gap: 8px 16px;
|
||||
align-items: center;
|
||||
margin-top: 32px;
|
||||
}
|
||||
|
||||
input[type=number] {
|
||||
width: 64px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
div.grid > div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
<form action="/configuration/notification/update/{{ if .Data.Service.Exists }}{{ .Data.Service.GetPrio }}{{ else }}-1{{ end }}" method="post">
|
||||
<input type="hidden" name="type" value="PUSHOVER">
|
||||
<div class="grid">
|
||||
<div>Prio:</div>
|
||||
<input type="number" min=0 name="prio" value="{{ .Data.Service.GetPrio }}">
|
||||
|
||||
<div>Description</div>
|
||||
<input type="text" name="description" value="{{ .Data.Service.Description }}" style="width: 100%">
|
||||
|
||||
<div>User key: <span class="error">*</span></div>
|
||||
<input type="text" name="user_key" value="{{ .Data.Service.UserKey }}" style="width: 100%">
|
||||
|
||||
<div>API (application) key: <span class="error">*</span></div>
|
||||
<input type="text" name="api_key" value="{{ .Data.Service.APIKey }}" style="width: 100%">
|
||||
|
||||
<div>Device name:</div>
|
||||
<input type="text" name="device_name" value="{{ .Data.Service.DeviceName }}" style="width: 100%">
|
||||
|
||||
<button style="grid-column: 1 / -1; width: min-content;">OK</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ end }}
|
@ -10,61 +10,9 @@
|
||||
evt.target.close()
|
||||
}
|
||||
}
|
||||
|
||||
function preset(hours) {
|
||||
const inputPreset = document.querySelector('input[name="preset"]')
|
||||
inputPreset.value = hours
|
||||
inputPreset.form.submit()
|
||||
}
|
||||
|
||||
function offsetTime(seconds) {
|
||||
const el = document.querySelector('input[name="offset-time"]')
|
||||
el.value = seconds
|
||||
el.form.submit()
|
||||
}
|
||||
</script>
|
||||
|
||||
<form action="/notifications" method="get">
|
||||
<input type="hidden" name="preset" value="">
|
||||
<input type="hidden" name="offset-time" value="">
|
||||
|
||||
<div id="time-select">
|
||||
<div>From</div>
|
||||
<div>To</div>
|
||||
<input name="f" value="{{ .Data.TimeFrom }}" type="datetime-local">
|
||||
<input name="t" value="{{ .Data.TimeTo }}" type="datetime-local">
|
||||
|
||||
<div id="time-offsets">
|
||||
<div class="header-1">Presets</div>
|
||||
<div class="header-2">Offsets</div>
|
||||
|
||||
<div class="preset"><a href="#" onclick="preset(1)">Last hour</a></div>
|
||||
|
||||
<div><a href="#" onclick="offsetTime(-3600)">◀</a></div>
|
||||
<div>Hour</div>
|
||||
<div><a href="#" onclick="offsetTime(3600)">▶</a></div>
|
||||
|
||||
<div class="preset"><a href="#" onclick="preset(24)">Last 24 hours</a></div>
|
||||
|
||||
<div><a href="#" onclick="offsetTime(-86400)">◀</a></div>
|
||||
<div>Day</div>
|
||||
<div><a href="#" onclick="offsetTime(86400)">▶</a></div>
|
||||
|
||||
<div class="preset"><a href="#" onclick="preset(24 * 7)">Last 7 days</a></div>
|
||||
|
||||
<div><a href="#" onclick="offsetTime(-7 * 86400)">◀</a></div>
|
||||
<div>Week</div>
|
||||
<div><a href="#" onclick="offsetTime(7 * 86400)">▶</a></div>
|
||||
|
||||
<div class="preset"><a href="#" onclick="preset(24 * 31)">Last 31 days</a></div>
|
||||
|
||||
<div><a href="#" onclick="offsetTime(-31 * 86400)">◀</a></div>
|
||||
<div>Month</div>
|
||||
<div><a href="#" onclick="offsetTime(31 * 86400)">▶</a></div>
|
||||
</div>
|
||||
<button>OK</button>
|
||||
</div>
|
||||
</form>
|
||||
{{ block "timefilter" . }}{{ end }}
|
||||
|
||||
<div id="notifications">
|
||||
<div class="header">Sent</div>
|
||||
|
@ -8,90 +8,156 @@
|
||||
</script>
|
||||
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/{{ .CONFIG.THEME }}/problems.css">
|
||||
|
||||
{{ block "page_label" . }}{{end}}
|
||||
{{ block "page_label" . }}{{ end }}
|
||||
|
||||
<div>
|
||||
<input type="radio" name="display" id="display-table" onclick="_ui.displayAreas()"> <label for="display-table">Areas</label>
|
||||
<input type="radio" name="display" id="display-list" onclick="_ui.displayList()"> <label for="display-list">List</label>
|
||||
<div style="margin-bottom: 16px; display: grid; grid-template-columns: min-content min-content; grid-gap: 32px;">
|
||||
<div style="white-space: nowrap">
|
||||
<b>Problem selection</b><br>
|
||||
<input {{ if eq .Data.Selection "CURRENT" }}checked{{ end }} type="radio" name="selection" id="selection-current" onclick="_ui.selectCurrent()"> <label for="selection-current">Current</label>
|
||||
<br>
|
||||
<input {{ if eq .Data.Selection "ALL" }}checked{{ end }} type="radio" name="selection" id="selection-all" onclick="_ui.selectAll()"> <label for="selection-all">All</label>
|
||||
</div>
|
||||
<div style="white-space: nowrap">
|
||||
<b>Show</b><br>
|
||||
<input type="radio" name="display" id="display-table" onclick="_ui.displayAreas()"> <label for="display-table">Areas</label>
|
||||
<br>
|
||||
<input type="radio" name="display" id="display-list" onclick="_ui.displayList()"> <label for="display-list">List</label>
|
||||
<div style="margin-top: 8px">
|
||||
<input type="checkbox" id="show-acked" onclick="_ui.toggleAcknowledged(event)"> <label for="show-acked">Show acknowledged</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{ block "timefilter" . }}{{ end }}
|
||||
|
||||
<div class="display-list hidden">
|
||||
<div id="problems-list">
|
||||
<div style="grid-column: 1/-1;"><h2>Current</h2></div>
|
||||
<div id="problems-list" class="table">
|
||||
<div class="row"><h2>Unacknowledged</h2></div>
|
||||
|
||||
<div class="header">OK</div>
|
||||
<div class="header">Trigger</div>
|
||||
<div class="header">Area</div>
|
||||
<div class="header">Section</div>
|
||||
<div class="header">Since</div>
|
||||
<div class="header">Until</div>
|
||||
<div class="header"></div>
|
||||
|
||||
{{ range .Data.Problems }}
|
||||
{{ if .Acknowledged }}
|
||||
{{ continue }}
|
||||
{{ end }}
|
||||
<div class="line"></div>
|
||||
|
||||
{{/* NODATA datapoints */}}
|
||||
{{ if eq .TriggerID -1 }}
|
||||
<div class="{{ if .Acknowledged }}acked hidden{{ end }}">{{ if .IsArchived }}<img src="/images/{{ $version }}/{{ $theme }}/ok.svg">{{ else }}<img src="/images/{{ $version }}/{{ $theme }}/warning.svg">{{ end }}</div>
|
||||
<div class="trigger">{{ .TriggerName }}</div>
|
||||
<div class="area">{{ .AreaName }}</div>
|
||||
<div class="section">{{ .SectionName }}</div>
|
||||
<div class="start"></div>
|
||||
<div class="acknowledge"><img src="/images/{{ $version }}/{{ $theme }}/acknowledge.svg"></div>
|
||||
<div class="start">{{ format_time .Start }}</div>
|
||||
<div class="end"></div>
|
||||
<div class="icons">
|
||||
<img class="info" src="/images/{{ $version }}/{{ $theme }}/info-outline.svg">
|
||||
<img class="acknowledge" src="/images/{{ $version }}/{{ $theme }}/acknowledge.svg">
|
||||
</div>
|
||||
{{ else }}
|
||||
<div class="{{ if .Acknowledged }}acked hidden{{ end }}">{{ if .IsArchived }}<img src="/images/{{ $version }}/{{ $theme }}/ok.svg">{{ else }}<img src="/images/{{ $version }}/{{ $theme }}/warning.svg">{{ end }}</div>
|
||||
<div class="trigger"><a href="/trigger/edit/{{ .TriggerID }}">{{ .TriggerName }}</a></div>
|
||||
<div class="area">{{ .AreaName }}</div>
|
||||
<div class="section">{{ .SectionName }}</div>
|
||||
<div class="start">{{ format_time .Start }}</div>
|
||||
<div class="acknowledge"><a href="/problem/acknowledge/{{ .ID }}"><img src="/images/{{ $version }}/{{ $theme }}/acknowledge-filled.svg"></a></div>
|
||||
<div class="end">{{ if not .End.IsZero }}{{ format_time .End }}{{ else }}-{{ end }}</div>
|
||||
<div class="icons">
|
||||
{{ if .FormattedValues }}
|
||||
<img class="info" src="/images/{{ $version }}/{{ $theme }}/info-filled.svg" title="{{ .FormattedValues }}">
|
||||
{{ else }}
|
||||
<img class="info" src="/images/{{ $version }}/{{ $theme }}/info-outline.svg">
|
||||
{{ end }}
|
||||
|
||||
<img class="acknowledge" onclick="location.href = '/problem/acknowledge/{{ .ID }}'" src="/images/{{ $version }}/{{ $theme }}/acknowledge-filled.svg">
|
||||
</div>
|
||||
{{ end }}
|
||||
{{ end }}
|
||||
</div>
|
||||
|
||||
<input type="checkbox" id="show-acked" onclick="_ui.toggleAcknowledged(event)"> <label for="show-acked">Show acknowledged</label>
|
||||
<div id="acknowledged-list" class="table hidden">
|
||||
<div class="row"><h2>Acknowledged</h2></div>
|
||||
|
||||
<div id="acknowledged-list" class="hidden">
|
||||
<div style="grid-column: 1/-1;"><h2>Acknowledged</h2></div>
|
||||
<div class="header">OK</div>
|
||||
<div class="header">Trigger</div>
|
||||
<div class="header">Area</div>
|
||||
<div class="header">Section</div>
|
||||
<div class="header">Since</div>
|
||||
<div class="header">Until</div>
|
||||
<div class="header"></div>
|
||||
|
||||
{{ range .Data.Problems }}
|
||||
{{ if not .Acknowledged }}
|
||||
{{ continue }}
|
||||
{{ end }}
|
||||
<div class="line"></div>
|
||||
<div class="{{ if .Acknowledged }}acked hidden{{ end }}">{{ if .IsArchived }}<img src="/images/{{ $version }}/{{ $theme }}/ok.svg">{{ else }}<img src="/images/{{ $version }}/{{ $theme }}/warning.svg">{{ end }}</div>
|
||||
<div class="trigger"><a href="/trigger/edit/{{ .TriggerID }}">{{ .TriggerName }}</a></div>
|
||||
<div class="area">{{ .AreaName }}</div>
|
||||
<div class="section">{{ .SectionName }}</div>
|
||||
<div class="start">{{ format_time .Start }}</div>
|
||||
<div class="acknowledge"><a href="/problem/unacknowledge/{{ .ID }}"><img src="/images/{{ $version }}/{{ $theme }}/acknowledge-outline.svg"></a></div>
|
||||
<div class="end">{{ if not .End.IsZero }}{{ format_time .End }}{{ else }}-{{ end }}</div>
|
||||
<div class="icons">
|
||||
{{ if .FormattedValues }}
|
||||
<img class="info" src="/images/{{ $version }}/{{ $theme }}/info-filled.svg" title="{{ .FormattedValues }}">
|
||||
{{ else }}
|
||||
<img class="info" src="/images/{{ $version }}/{{ $theme }}/info-outline.svg">
|
||||
{{ end }}
|
||||
<img class="acknowledge" onclick="location.href = '/problem/unacknowledge/{{ .ID }}'" src="/images/{{ $version }}/{{ $theme }}/acknowledge-outline.svg">
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="display-areas hidden">
|
||||
<div id="areas">
|
||||
<div id="area-grouped">
|
||||
{{ range $areaName, $sections := .Data.ProblemsGrouped }}
|
||||
<div class="area">
|
||||
<div class="name">{{ $areaName }}</div>
|
||||
<div class="area table">
|
||||
<div class="row"><h2>{{ $areaName }}</h2></div>
|
||||
|
||||
{{ range $sectionName, $problems := $sections }}
|
||||
<div class="section problems">
|
||||
<div class="name">{{ $sectionName }}</div>
|
||||
<div class="section row" style="margin-top: 16px; font-weight: bold;">{{ $sectionName }}</div>
|
||||
|
||||
<div class="header">OK</div>
|
||||
<div class="header">Trigger</div>
|
||||
<div class="header">Since</div>
|
||||
<div class="header">Until</div>
|
||||
<div class="header"></div>
|
||||
<div class="line"></div>
|
||||
|
||||
{{ range $problems }}
|
||||
<div class="trigger">{{ .TriggerName }}</div>
|
||||
<div class="{{ if .Acknowledged }}acked hidden{{ end }}">{{ if .IsArchived }}<img src="/images/{{ $version }}/{{ $theme }}/ok.svg">{{ else }}<img src="/images/{{ $version }}/{{ $theme }}/warning.svg">{{ end }}</div>
|
||||
|
||||
{{ if eq (.Start | html) "0001-01-01 00:00:00 +0000 UTC" }}
|
||||
<div class="since"></div>
|
||||
<div class="{{ if .Acknowledged }}acked hidden{{ end }} trigger">{{ .TriggerName }}</div>
|
||||
|
||||
<div class="{{ if .Acknowledged }}acked hidden{{ end }} since">{{ if not .Start.IsZero }}{{ format_time .Start }}{{ else }}-{{ end }}</div>
|
||||
<div class="{{ if .Acknowledged }}acked hidden{{ end }} until">{{ if not .End.IsZero }}{{ format_time .End }}{{ else }}-{{ end }}</div>
|
||||
|
||||
{{ if .FormattedValues }}
|
||||
<div class="{{ if .Acknowledged }}acked hidden{{ end }}"><img src="/images/{{ $version }}/{{ $theme }}/info-filled.svg" title="{{ .FormattedValues }}"></div>
|
||||
{{ else }}
|
||||
<div class="since">{{ format_time .Start }}</div>
|
||||
<div class="{{ if .Acknowledged }}acked hidden{{ end }}"><img src="/images/{{ $version }}/{{ $theme }}/info-outline.svg"></div>
|
||||
{{ end }}
|
||||
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
{{ end }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
const form = document.getElementById('form-time-selector')
|
||||
const inputSelection = document.createElement('input')
|
||||
inputSelection.type = 'hidden'
|
||||
inputSelection.name = 'selection'
|
||||
inputSelection.value = '{{ if eq .Data.Selection "ALL" }}all{{ else }}current{{ end }}'
|
||||
form.append(inputSelection)
|
||||
</script>
|
||||
|
||||
{{ end }}
|
||||
|