Compare commits

...

51 Commits
v26 ... main

Author SHA1 Message Date
Magnus Åhall
8fa8bb4c3a Removed datapoint rename confirmation since everything is automatic 2024-07-25 10:59:03 +02:00
Magnus Åhall
7cf2b60803 Bumped to v41 2024-07-25 10:57:05 +02:00
Magnus Åhall
db41d360dc #6, update trigger expressions when renaming datapoints 2024-07-25 10:56:42 +02:00
Magnus Åhall
6685f8bc46 Bumped to v40 2024-07-25 09:39:48 +02:00
Magnus Åhall
96f7b50e4e UI confirm of datapoint renaming 2024-07-25 09:38:43 +02:00
Magnus Åhall
8ef6a2bbfa Added trigger list to datapoint edit 2024-07-25 08:38:06 +02:00
Magnus Åhall
d1599fe2b9 Fixed trigger edit regression and bumped to v39 2024-07-24 16:05:30 +02:00
Magnus Åhall
6909b223a7 Bumped to v38 2024-07-24 15:39:33 +02:00
Magnus Åhall
570ea064aa Faster datapoint latest value display 2024-07-24 15:39:04 +02:00
Magnus Åhall
f8a64e4dfd Added last values for table datapoint, update to current latest values and trigger for future values 2024-07-24 10:27:21 +02:00
Magnus Åhall
a8bdeae3a9 Trying NTFY priority of 5 to get better Firebase notifications 2024-07-19 10:11:53 +02:00
Magnus Åhall
1deb80c776 Bumped to v37 2024-07-07 15:52:08 +02:00
Magnus Åhall
85a6da0b0a Added link to datapoint values from trigger 2024-07-07 15:51:51 +02:00
Magnus Åhall
17e555e7fc Bumped to v36 2024-07-06 09:43:28 +02:00
Magnus Åhall
29e1001665 Regex-based datapoint filter 2024-07-06 09:43:18 +02:00
Magnus Åhall
fe48cf780e Bumped to v35 2024-07-06 09:06:17 +02:00
Magnus Åhall
169c881134 Updated screenshots, fixed graph height 2024-07-06 08:36:49 +02:00
Magnus Åhall
e55e4261dd Updated problems screenshot 2024-07-05 19:55:33 +02:00
Magnus Åhall
c3fbfa307f Bumped to v34 2024-07-05 14:52:14 +02:00
Magnus Åhall
cd123ae1c1 Added button to apply timefilter 2024-07-05 14:52:02 +02:00
Magnus Åhall
9689283c0e Bumped to v33 2024-07-05 12:05:21 +02:00
Magnus Åhall
3adf85a0f6 Redesign of time filter UI 2024-07-05 12:04:56 +02:00
Magnus Åhall
2c5b434fd2 Refactored table CSS, bettered layout in problems 2024-07-05 10:47:50 +02:00
Magnus Åhall
1215b13d47 Bumped to v32 2024-07-04 19:48:05 +02:00
Magnus Åhall
e10783ec54 Fixed error message when trying to change prio to a used number. 2024-07-04 19:38:36 +02:00
Magnus Åhall
06f88f697c Added description to Pushover notification service. 2024-07-04 19:37:18 +02:00
Magnus Åhall
414ca0a95c Reprioritize notification services when updated. 2024-07-04 19:36:56 +02:00
Magnus Åhall
257a4968ec Added Pushover notification 2024-07-04 19:21:02 +02:00
Magnus Åhall
5d24baedac Bumped to v31 2024-07-04 16:55:29 +02:00
Magnus Åhall
fefd4af10c Present times in configured timezone 2024-07-04 16:54:23 +02:00
Magnus Åhall
0de7ca4bef Immediate value presentation when adding datapoint to trigger 2024-07-04 16:48:57 +02:00
Magnus Åhall
3227c22de1 Bumped to v30 2024-07-04 16:30:14 +02:00
Magnus Åhall
1bef8719c0 UI adjustments for problems view 2024-07-04 16:29:53 +02:00
Magnus Åhall
b53b507355 Added SQL for problem 2024-07-04 16:06:11 +02:00
Magnus Åhall
4d7f0d557e Save name of trigger in problem 2024-07-04 15:59:29 +02:00
Magnus Åhall
09241e73a5 UI changes for problems 2024-07-04 15:14:24 +02:00
Magnus Åhall
332788dd20 Bumped to v29 2024-07-04 13:37:24 +02:00
Magnus Åhall
f7dcb4a079 Added timefilter to problems 2024-07-04 13:37:06 +02:00
Magnus Åhall
9700bc9d3c Refactored time filter. 2024-07-04 13:29:39 +02:00
Magnus Åhall
4c908f4891 Renamed to werr for wrapped error library 2024-07-04 09:25:34 +02:00
Magnus Åhall
0f69874475 Bumped to v28 2024-07-04 09:21:46 +02:00
Magnus Åhall
a985f531ea Better datetime display for problems 2024-07-04 09:21:46 +02:00
Magnus Åhall
df714c750b Info icon for problem values 2024-07-04 09:21:46 +02:00
Magnus Åhall
aa368c0b0d Store datapoint values with the problems 2024-07-04 09:21:46 +02:00
865f1ee184 Update README.md 2024-06-30 12:58:27 +02:00
6108cb7046 Update README.md 2024-06-30 12:54:58 +02:00
Magnus Åhall
89c7c30980 Added screenshots 2024-06-30 12:39:24 +02:00
854be4985f Update README.md 2024-06-30 12:28:49 +02:00
Magnus Åhall
3506566e45 Removed session daysvalid 2024-06-30 11:42:00 +02:00
Magnus Åhall
51f96cddb1 Bumped to v27 2024-06-30 11:28:09 +02:00
Magnus Åhall
df8e3fba23 Added filtering of datapoints 2024-06-30 11:27:49 +02:00
69 changed files with 2027 additions and 675 deletions

145
README.md
View File

@ -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`.

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

@ -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(

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
screenshots/datapoints.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

BIN
screenshots/problems.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

BIN
screenshots/triggers.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

4
sql/00022.sql Normal file
View 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
View File

@ -0,0 +1 @@
ALTER TABLE public.problem ADD COLUMN trigger_expression VARCHAR NOT NULL DEFAULT '';

1
sql/00024.sql Normal file
View File

@ -0,0 +1 @@
ALTER TABLE public.problem ADD COLUMN trigger_name VARCHAR NOT NULL DEFAULT '[Unknown]';

1
sql/00025.sql Normal file
View File

@ -0,0 +1 @@
ALTER TYPE notification_type ADD VALUE 'PUSHOVER';

62
sql/00026.sql Normal file
View 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
View 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();

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;

View 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;
}

View File

@ -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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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;

View 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;
}

View File

@ -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;

View 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

View 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

View 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

View 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

View 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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
View 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;
}
}

View File

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

View File

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

View 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%">&nbsp;</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 }}

View File

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

View File

@ -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">

View File

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

View 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 }}

View File

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

View File

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