Compare commits
4 commits
1c697a028b
...
8b51b444f6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b51b444f6 | ||
|
|
dc0ad136e6 | ||
|
|
c3fe5951d4 | ||
|
|
1af3f59b1a |
15 changed files with 2345 additions and 27 deletions
69
dashboard.go
Normal file
69
dashboard.go
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
// External
|
||||||
|
werr "git.ahall.se/go/wrappederror"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
// Standard
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
)
|
||||||
|
|
||||||
|
type WidgetType int
|
||||||
|
|
||||||
|
const (
|
||||||
|
WIDGET_SPACE WidgetType = iota
|
||||||
|
WIDGET_LABEL
|
||||||
|
WIDGET_ONOFF
|
||||||
|
)
|
||||||
|
|
||||||
|
type Widget struct {
|
||||||
|
Type WidgetType
|
||||||
|
X int
|
||||||
|
Y int
|
||||||
|
SpanX int
|
||||||
|
SpanY int
|
||||||
|
DatapointID int
|
||||||
|
Attributes map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
type Dashboard struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Widgets []Widget
|
||||||
|
}
|
||||||
|
|
||||||
|
func DashboardGet(name string) (dashboard Dashboard, err error) {
|
||||||
|
var rows pgx.Rows
|
||||||
|
rows, err = db.Query(context.Background(), `SELECT * FROM dashboard WHERE name = $1`, name)
|
||||||
|
if err != nil {
|
||||||
|
err = werr.Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
dashboard, err = pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Dashboard])
|
||||||
|
if err != nil {
|
||||||
|
err = werr.Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
func DashboardUpdate(dashboard Dashboard) error {
|
||||||
|
widgets, _ := json.Marshal(dashboard.Widgets)
|
||||||
|
|
||||||
|
_, err := db.Exec(
|
||||||
|
context.Background(),
|
||||||
|
`UPDATE dashboard SET name=$2, widgets=$3 WHERE id=$1`,
|
||||||
|
dashboard.ID,
|
||||||
|
dashboard.Name,
|
||||||
|
widgets,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return werr.Wrap(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
76
datapoint.go
76
datapoint.go
|
|
@ -28,13 +28,35 @@ type Datapoint struct {
|
||||||
Datatype DatapointType
|
Datatype DatapointType
|
||||||
Comment string
|
Comment string
|
||||||
LastValue time.Time `db:"last_value"`
|
LastValue time.Time `db:"last_value"`
|
||||||
|
DataPointID int `db:"datapoint_id"`
|
||||||
DatapointValueJSON []byte `db:"datapoint_value_json"`
|
DatapointValueJSON []byte `db:"datapoint_value_json"`
|
||||||
LastDatapointValue DatapointValue
|
LastDatapointValue DatapointValue
|
||||||
|
LastDatapointID *int `db:"last_value_id"`
|
||||||
|
LastValueInt *int `db:"last_value_int"`
|
||||||
|
LastValueString *string `db:"last_value_string"`
|
||||||
|
LastValueDateTime *time.Time `db:"last_value_datetime"`
|
||||||
Found bool
|
Found bool
|
||||||
NodataProblemSeconds int `db:"nodata_problem_seconds"`
|
NodataProblemSeconds int `db:"nodata_problem_seconds"`
|
||||||
NodataIsProblem bool `db:"nodata_is_problem"`
|
NodataIsProblem bool `db:"nodata_is_problem"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DatapointBrief struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
|
Comment string
|
||||||
|
Datatype DatapointType
|
||||||
|
LastValueInt *int `db:"last_value_int"`
|
||||||
|
LastValueString *string `db:"last_value_string"`
|
||||||
|
LastValueDateTime *time.Time `db:"last_value_datetime"`
|
||||||
|
NodataIsProblem bool `db:"nodata_is_problem"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type DatapointTiny struct {
|
||||||
|
ID int
|
||||||
|
Value any
|
||||||
|
Valid bool
|
||||||
|
}
|
||||||
|
|
||||||
type DatapointValue struct {
|
type DatapointValue struct {
|
||||||
ID int
|
ID int
|
||||||
DatapointID int `db:"datapoint_id"`
|
DatapointID int `db:"datapoint_id"`
|
||||||
|
|
@ -262,7 +284,7 @@ func DatapointRetrieve(id int, name string) (dp Datapoint, err error) { // {{{
|
||||||
}
|
}
|
||||||
|
|
||||||
rows, _ := db.Query(context.Background(), query, param)
|
rows, _ := db.Query(context.Background(), query, param)
|
||||||
dp, err = pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Datapoint])
|
dp, err = pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Datapoint])
|
||||||
if err != nil {
|
if err != nil {
|
||||||
err = werr.Wrap(err)
|
err = werr.Wrap(err)
|
||||||
return
|
return
|
||||||
|
|
@ -292,7 +314,7 @@ func DatapointRetrieve(id int, name string) (dp Datapoint, err error) { // {{{
|
||||||
`,
|
`,
|
||||||
dp.ID,
|
dp.ID,
|
||||||
)
|
)
|
||||||
dp, err = pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Datapoint])
|
dp.LastDatapointValue, err = pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[DatapointValue])
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
err = nil
|
err = nil
|
||||||
return
|
return
|
||||||
|
|
@ -370,3 +392,53 @@ func DatapointValues(id int, from, to time.Time) (values []DatapointValue, err e
|
||||||
|
|
||||||
return
|
return
|
||||||
} // }}}
|
} // }}}
|
||||||
|
|
||||||
|
// DatapointsValue only returns ID and type and is used primarily for the dashboard.
|
||||||
|
func DatapointsValue(ids []int) (values []DatapointTiny, err error) { // {{{
|
||||||
|
values = []DatapointTiny{}
|
||||||
|
|
||||||
|
rows, _ := db.Query(
|
||||||
|
context.Background(),
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
comment,
|
||||||
|
datatype,
|
||||||
|
last_value_int,
|
||||||
|
last_value_string,
|
||||||
|
last_value_datetime,
|
||||||
|
nodata_is_problem
|
||||||
|
FROM datapoint
|
||||||
|
WHERE
|
||||||
|
id = ANY($1)
|
||||||
|
`,
|
||||||
|
ids,
|
||||||
|
)
|
||||||
|
|
||||||
|
var briefs []DatapointBrief
|
||||||
|
briefs, err = pgx.CollectRows(rows, pgx.RowToStructByNameLax[DatapointBrief])
|
||||||
|
if err != nil {
|
||||||
|
err = werr.Wrap(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, brief := range briefs {
|
||||||
|
tiny := DatapointTiny{}
|
||||||
|
tiny.ID = brief.ID
|
||||||
|
tiny.Valid = !brief.NodataIsProblem
|
||||||
|
|
||||||
|
switch brief.Datatype {
|
||||||
|
case "INT":
|
||||||
|
tiny.Value = brief.LastValueInt
|
||||||
|
case "STRING":
|
||||||
|
tiny.Value = brief.LastValueString
|
||||||
|
case "DATETIME":
|
||||||
|
tiny.Value = brief.LastValueDateTime
|
||||||
|
}
|
||||||
|
|
||||||
|
values = append(values, tiny)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
} // }}}
|
||||||
|
|
|
||||||
2
go.mod
2
go.mod
|
|
@ -1,6 +1,6 @@
|
||||||
module smon
|
module smon
|
||||||
|
|
||||||
go 1.25.0
|
go 1.26.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
git.ahall.se/go/html_template v0.1.1
|
git.ahall.se/go/html_template v0.1.1
|
||||||
|
|
|
||||||
63
main.go
63
main.go
|
|
@ -29,7 +29,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const VERSION = "v41"
|
const VERSION = "v42"
|
||||||
|
|
||||||
var (
|
var (
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
@ -160,6 +160,9 @@ func main() { // {{{
|
||||||
http.HandleFunc("/configuration/notification/delete/{prio}", actionConfigurationNotificationDelete)
|
http.HandleFunc("/configuration/notification/delete/{prio}", actionConfigurationNotificationDelete)
|
||||||
http.HandleFunc("/entry/{datapoint}", actionEntryDatapoint)
|
http.HandleFunc("/entry/{datapoint}", actionEntryDatapoint)
|
||||||
|
|
||||||
|
http.HandleFunc("GET /dashboard/values", actionDashboardValues)
|
||||||
|
http.HandleFunc("POST /dashboard/update", actionDashboardUpdate)
|
||||||
|
|
||||||
go nodataLoop()
|
go nodataLoop()
|
||||||
|
|
||||||
smonConfig, err = SmonConfigInit()
|
smonConfig, err = SmonConfigInit()
|
||||||
|
|
@ -405,8 +408,52 @@ func pageIndex(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
PAGE: "index",
|
PAGE: "index",
|
||||||
CONFIG: smonConfig.Settings,
|
CONFIG: smonConfig.Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dashboard, err := DashboardGet("start")
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("dashboard", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := make(map[string]any)
|
||||||
|
data["Dashboard"] = dashboard
|
||||||
|
page.Data = data
|
||||||
page.Render(w, r)
|
page.Render(w, r)
|
||||||
} // }}}
|
} // }}}
|
||||||
|
func actionDashboardValues(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
|
values, err := DatapointsValue([]int{13, 15})
|
||||||
|
if err != nil {
|
||||||
|
logger.Error("dashboard", "error", err)
|
||||||
|
httpError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
j, _ := json.Marshal(struct {
|
||||||
|
OK bool
|
||||||
|
Values []DatapointTiny
|
||||||
|
}{
|
||||||
|
true,
|
||||||
|
values,
|
||||||
|
})
|
||||||
|
w.Write(j)
|
||||||
|
} // }}}
|
||||||
|
func actionDashboardUpdate(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
|
var dashboard Dashboard
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
err := json.Unmarshal(body, &dashboard)
|
||||||
|
if err != nil {
|
||||||
|
httpError(w, werr.Wrap(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = DashboardUpdate(dashboard)
|
||||||
|
if err != nil {
|
||||||
|
httpError(w, werr.Wrap(err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
j, _ := json.Marshal(struct{ OK bool }{true})
|
||||||
|
w.Write(j)
|
||||||
|
} // }}}
|
||||||
|
|
||||||
func actionAreaNew(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionAreaNew(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
|
|
@ -418,7 +465,6 @@ func actionAreaNew(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
|
|
||||||
w.Header().Add("Location", "/configuration")
|
w.Header().Add("Location", "/configuration")
|
||||||
w.WriteHeader(302)
|
w.WriteHeader(302)
|
||||||
return
|
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionAreaRename(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionAreaRename(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
idStr := r.PathValue("id")
|
idStr := r.PathValue("id")
|
||||||
|
|
@ -437,7 +483,6 @@ func actionAreaRename(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
|
|
||||||
w.Header().Add("Location", "/configuration")
|
w.Header().Add("Location", "/configuration")
|
||||||
w.WriteHeader(302)
|
w.WriteHeader(302)
|
||||||
return
|
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionAreaDelete(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionAreaDelete(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
idStr := r.PathValue("id")
|
idStr := r.PathValue("id")
|
||||||
|
|
@ -455,7 +500,6 @@ func actionAreaDelete(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
|
|
||||||
w.Header().Add("Location", "/configuration")
|
w.Header().Add("Location", "/configuration")
|
||||||
w.WriteHeader(302)
|
w.WriteHeader(302)
|
||||||
return
|
|
||||||
} // }}}
|
} // }}}
|
||||||
|
|
||||||
func actionSectionNew(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionSectionNew(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
|
|
@ -475,7 +519,6 @@ func actionSectionNew(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
|
|
||||||
w.Header().Add("Location", "/configuration")
|
w.Header().Add("Location", "/configuration")
|
||||||
w.WriteHeader(302)
|
w.WriteHeader(302)
|
||||||
return
|
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionSectionRename(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionSectionRename(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
idStr := r.PathValue("id")
|
idStr := r.PathValue("id")
|
||||||
|
|
@ -494,7 +537,6 @@ func actionSectionRename(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
|
|
||||||
w.Header().Add("Location", "/configuration")
|
w.Header().Add("Location", "/configuration")
|
||||||
w.WriteHeader(302)
|
w.WriteHeader(302)
|
||||||
return
|
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionSectionDelete(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionSectionDelete(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
idStr := r.PathValue("id")
|
idStr := r.PathValue("id")
|
||||||
|
|
@ -512,7 +554,6 @@ func actionSectionDelete(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
|
|
||||||
w.Header().Add("Location", "/configuration")
|
w.Header().Add("Location", "/configuration")
|
||||||
w.WriteHeader(302)
|
w.WriteHeader(302)
|
||||||
return
|
|
||||||
} // }}}
|
} // }}}
|
||||||
|
|
||||||
func pageProblems(w http.ResponseWriter, r *http.Request) { // {{{
|
func pageProblems(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
|
|
@ -577,7 +618,6 @@ func pageProblems(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
|
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
|
||||||
}
|
}
|
||||||
page.Render(w, r)
|
page.Render(w, r)
|
||||||
return
|
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionProblemAcknowledge(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionProblemAcknowledge(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
idStr := r.PathValue("id")
|
idStr := r.PathValue("id")
|
||||||
|
|
@ -595,8 +635,6 @@ func actionProblemAcknowledge(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
|
|
||||||
w.Header().Add("Location", "/problems")
|
w.Header().Add("Location", "/problems")
|
||||||
w.WriteHeader(302)
|
w.WriteHeader(302)
|
||||||
|
|
||||||
return
|
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionProblemUnacknowledge(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionProblemUnacknowledge(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
idStr := r.PathValue("id")
|
idStr := r.PathValue("id")
|
||||||
|
|
@ -614,8 +652,6 @@ func actionProblemUnacknowledge(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
|
|
||||||
w.Header().Add("Location", "/problems")
|
w.Header().Add("Location", "/problems")
|
||||||
w.WriteHeader(302)
|
w.WriteHeader(302)
|
||||||
|
|
||||||
return
|
|
||||||
} // }}}
|
} // }}}
|
||||||
|
|
||||||
func pageDatapoints(w http.ResponseWriter, r *http.Request) { // {{{
|
func pageDatapoints(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
|
|
@ -642,7 +678,6 @@ func pageDatapoints(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
"Datapoints": datapoints,
|
"Datapoints": datapoints,
|
||||||
}
|
}
|
||||||
page.Render(w, r)
|
page.Render(w, r)
|
||||||
return
|
|
||||||
} // }}}
|
} // }}}
|
||||||
func pageDatapointEdit(w http.ResponseWriter, r *http.Request) { // {{{
|
func pageDatapointEdit(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
idStr := r.PathValue("id")
|
idStr := r.PathValue("id")
|
||||||
|
|
@ -699,7 +734,6 @@ func pageDatapointEdit(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
"Triggers": triggers,
|
"Triggers": triggers,
|
||||||
}
|
}
|
||||||
page.Render(w, r)
|
page.Render(w, r)
|
||||||
return
|
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionDatapointUpdate(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionDatapointUpdate(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
idStr := r.PathValue("id")
|
idStr := r.PathValue("id")
|
||||||
|
|
@ -832,7 +866,6 @@ func pageDatapointValues(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
|
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
|
||||||
}
|
}
|
||||||
page.Render(w, r)
|
page.Render(w, r)
|
||||||
return
|
|
||||||
} // }}}
|
} // }}}
|
||||||
func actionDatapointJson(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionDatapointJson(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
idStr := r.PathValue("id")
|
idStr := r.PathValue("id")
|
||||||
|
|
|
||||||
|
|
@ -3,11 +3,13 @@ package main
|
||||||
import (
|
import (
|
||||||
// External
|
// External
|
||||||
werr "git.ahall.se/go/wrappederror"
|
werr "git.ahall.se/go/wrappederror"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
// Standard
|
// Standard
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -152,7 +154,7 @@ func ProblemClose(trigger Trigger) (problemID int, err error) { // {{{
|
||||||
)
|
)
|
||||||
|
|
||||||
err = row.Scan(&problemID)
|
err = row.Scan(&problemID)
|
||||||
if err == sql.ErrNoRows {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
err = nil
|
err = nil
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
7
sql/00028.sql
Normal file
7
sql/00028.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
CREATE TABLE public.dashboard (
|
||||||
|
id serial NOT NULL,
|
||||||
|
"name" varchar NOT NULL,
|
||||||
|
widgets jsonb DEFAULT '{}' NOT NULL,
|
||||||
|
CONSTRAINT dashboard_pk PRIMARY KEY (id),
|
||||||
|
CONSTRAINT dashboard_name_unique UNIQUE ("name")
|
||||||
|
);
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
body {
|
body {
|
||||||
background-image: url(/images/v0/gruvbox/background.svg);
|
/* background-image: url(/images/v0/gruvbox/background.svg); */
|
||||||
}
|
}
|
||||||
#menu {
|
#menu {
|
||||||
box-shadow: 2px 0px 5px 3px rgba(0, 0, 0, 0.25);
|
box-shadow: 2px 0px 5px 3px rgba(0, 0, 0, 0.25);
|
||||||
|
|
|
||||||
1123
static/images/grid_management.svg
Normal file
1123
static/images/grid_management.svg
Normal file
File diff suppressed because it is too large
Load diff
|
After Width: | Height: | Size: 26 KiB |
49
static/images/widget_light_green.svg
Normal file
49
static/images/widget_light_green.svg
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="31.999998"
|
||||||
|
height="31.999998"
|
||||||
|
viewBox="0 0 8.466666 8.4666661"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
|
||||||
|
sodipodi:docname="widget_light_on.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">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#ffffff"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
showborder="false"
|
||||||
|
inkscape:zoom="32"
|
||||||
|
inkscape:cx="18.171875"
|
||||||
|
inkscape:cy="16.25"
|
||||||
|
inkscape:window-width="1916"
|
||||||
|
inkscape:window-height="1161"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<circle
|
||||||
|
style="fill:#55d400;stroke:none;stroke-width:1.00012"
|
||||||
|
id="circle1"
|
||||||
|
cx="4.2333331"
|
||||||
|
cy="4.2333331"
|
||||||
|
r="3.7041667" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
49
static/images/widget_light_off.svg
Normal file
49
static/images/widget_light_off.svg
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="31.999998"
|
||||||
|
height="31.999998"
|
||||||
|
viewBox="0 0 8.466666 8.4666661"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
|
||||||
|
sodipodi:docname="widget_light_off.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">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#ffffff"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
showborder="false"
|
||||||
|
inkscape:zoom="32"
|
||||||
|
inkscape:cx="18.171875"
|
||||||
|
inkscape:cy="16.25"
|
||||||
|
inkscape:window-width="1916"
|
||||||
|
inkscape:window-height="1161"
|
||||||
|
inkscape:window-x="0"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<circle
|
||||||
|
style="fill:#666666;stroke:none;stroke-width:1.00012;fill-opacity:1"
|
||||||
|
id="circle1"
|
||||||
|
cx="4.2333331"
|
||||||
|
cy="4.2333331"
|
||||||
|
r="3.7041667" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
49
static/images/widget_light_red.svg
Normal file
49
static/images/widget_light_red.svg
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||||
|
|
||||||
|
<svg
|
||||||
|
width="31.999998"
|
||||||
|
height="31.999998"
|
||||||
|
viewBox="0 0 8.466666 8.4666661"
|
||||||
|
version="1.1"
|
||||||
|
id="svg1"
|
||||||
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
|
sodipodi:docname="widget_light_red.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">
|
||||||
|
<sodipodi:namedview
|
||||||
|
id="namedview1"
|
||||||
|
pagecolor="#ffffff"
|
||||||
|
bordercolor="#000000"
|
||||||
|
borderopacity="0.25"
|
||||||
|
inkscape:showpageshadow="2"
|
||||||
|
inkscape:pageopacity="0.0"
|
||||||
|
inkscape:pagecheckerboard="0"
|
||||||
|
inkscape:deskcolor="#ffffff"
|
||||||
|
inkscape:document-units="px"
|
||||||
|
showborder="false"
|
||||||
|
inkscape:zoom="32"
|
||||||
|
inkscape:cx="18.1875"
|
||||||
|
inkscape:cy="16.265625"
|
||||||
|
inkscape:window-width="1093"
|
||||||
|
inkscape:window-height="1401"
|
||||||
|
inkscape:window-x="2560"
|
||||||
|
inkscape:window-y="0"
|
||||||
|
inkscape:window-maximized="1"
|
||||||
|
inkscape:current-layer="layer1" />
|
||||||
|
<defs
|
||||||
|
id="defs1" />
|
||||||
|
<g
|
||||||
|
inkscape:label="Layer 1"
|
||||||
|
inkscape:groupmode="layer"
|
||||||
|
id="layer1">
|
||||||
|
<circle
|
||||||
|
style="fill:#ff2a2a;stroke:none;stroke-width:0.79399899;fill-opacity:1"
|
||||||
|
id="circle1"
|
||||||
|
cx="4.2333331"
|
||||||
|
cy="4.2333331"
|
||||||
|
r="3.7041667" />
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.4 KiB |
805
static/js/dashboard.mjs
Normal file
805
static/js/dashboard.mjs
Normal file
|
|
@ -0,0 +1,805 @@
|
||||||
|
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||||
|
|
||||||
|
const WIDGET_SPACE = 0
|
||||||
|
const WIDGET_LABEL = 1
|
||||||
|
const WIDGET_ONOFF = 2
|
||||||
|
|
||||||
|
export class SmonDashboard extends CustomHTMLElement {// {{{
|
||||||
|
static {// {{{
|
||||||
|
this.tmpl = document.createElement('template')
|
||||||
|
this.tmpl.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
--edit-border: 1px solid #888;
|
||||||
|
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host(.edit) {
|
||||||
|
.el-grid {
|
||||||
|
border-top: var(--edit-border);
|
||||||
|
border-left: var(--edit-border);
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
border-right: var(--edit-border);
|
||||||
|
border-bottom: var(--edit-border);
|
||||||
|
padding: 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > *:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.1) !important;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
smon-widget-space {
|
||||||
|
background: rgba(128, 255, 128, 0.10);
|
||||||
|
}
|
||||||
|
|
||||||
|
& > smon-widget-empty {
|
||||||
|
display: block;
|
||||||
|
background: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.only-on-edit {
|
||||||
|
display: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-error {
|
||||||
|
display: none;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
width: min-content;
|
||||||
|
white-space: nowrap;
|
||||||
|
background-color: #ff2a2a;
|
||||||
|
color: #fff;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 1.25em;
|
||||||
|
padding: 16px 32px;
|
||||||
|
border-radius: 8px;
|
||||||
|
|
||||||
|
&.show {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: min-content;
|
||||||
|
width: min-content;
|
||||||
|
|
||||||
|
smon-widget-empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > * {
|
||||||
|
padding: 4px 0px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.only-on-edit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: min-content;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
svg {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="text"] {
|
||||||
|
padding: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-bottom: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<button data-el="edit-dashboard">Edit dashboard</button>
|
||||||
|
<button class="only-on-edit" data-el="add-columns">Add columns</button>
|
||||||
|
<button class="only-on-edit" data-el="add-rows">Add rows</button>
|
||||||
|
<button class="only-on-edit" data-el="update-dashboard">Update dashboard</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-el="error"></div>
|
||||||
|
<div data-el="grid">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dialog data-el="edit">
|
||||||
|
<div style="font-weight: bold;">Cell operations</div>
|
||||||
|
<div style="margin-top: 8px; margin-bottom: 16px;">
|
||||||
|
<div>
|
||||||
|
<svg data-el="grid-add-left" style="width: 36px; height: 36px;"><use href="/images/${_VERSION}/grid_management.svg#add-left" /></svg>
|
||||||
|
<svg data-el="grid-add-right" style="width: 36px; height: 36px;"><use href="/images/${_VERSION}/grid_management.svg#add-right" /></svg>
|
||||||
|
<svg data-el="grid-add-above" style="width: 36px; height: 36px;"><use href="/images/${_VERSION}/grid_management.svg#add-above" /></svg>
|
||||||
|
<svg data-el="grid-add-below" style="width: 36px; height: 36px;"><use href="/images/${_VERSION}/grid_management.svg#add-below" /></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<svg data-el="grid-add-column-left" style="width: 36px; height: 36px;"><use href="/images/${_VERSION}/grid_management.svg#add-column-left" /></svg>
|
||||||
|
<svg data-el="grid-add-column-above" style="width: 36px; height: 36px;"><use href="/images/${_VERSION}/grid_management.svg#add-column-above" /></svg>
|
||||||
|
<svg data-el="grid-remove-column" style="width: 36px; height: 36px;"><use href="/images/${_VERSION}/grid_management.svg#remove-column" /></svg>
|
||||||
|
<svg data-el="grid-remove-row" style="width: 36px; height: 36px;"><use href="/images/${_VERSION}/grid_management.svg#remove-row" /></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<svg data-el="grid-remove" style="width: 36px; height: 36px;"><use href="/images/${_VERSION}/grid_management.svg#remove" /></svg>
|
||||||
|
<svg data-el="grid-remove-move-left" style="width: 36px; height: 36px;"><use href="/images/${_VERSION}/grid_management.svg#remove-move-left" /></svg>
|
||||||
|
<svg data-el="grid-remove-move-up" style="width: 36px; height: 36px;"><use href="/images/${_VERSION}/grid_management.svg#remove-move-up" /></svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<div data-el="edit-components"></div>
|
||||||
|
<div style="display: flex; gap: 8px; margin-top: 16px;">
|
||||||
|
<button data-el="edit-update">Update</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</dialog>
|
||||||
|
`
|
||||||
|
}// }}}
|
||||||
|
constructor() {// {{{
|
||||||
|
super(true)
|
||||||
|
const script = this.querySelector('script')
|
||||||
|
this.data = JSON.parse(script.textContent)
|
||||||
|
this.widgets = new Map() // keyed by datapoint ID, value is an array of widgets.
|
||||||
|
this.addColumns = 0
|
||||||
|
this.addRows = 0
|
||||||
|
this.editedWidget = null
|
||||||
|
this.widgetsEdited = false
|
||||||
|
|
||||||
|
this.elEditDashboard.addEventListener('click', () => this.classList.toggle('edit'))
|
||||||
|
this.elAddColumns.addEventListener('click', () => { this.addColumns += 5; this.render() })
|
||||||
|
this.elAddRows.addEventListener('click', () => { this.addRows += 5; this.render() })
|
||||||
|
this.elUpdateDashboard.addEventListener('click', () => this.updateDashboard())
|
||||||
|
this.elEditUpdate.addEventListener('click', () => this.editUpdate())
|
||||||
|
|
||||||
|
this.elGridAddLeft.addEventListener('click', () => this.gridAddLeft())
|
||||||
|
this.elGridAddRight.addEventListener('click', () => this.gridAddRight())
|
||||||
|
this.elGridAddAbove.addEventListener('click', () => this.gridAddAbove())
|
||||||
|
this.elGridAddBelow.addEventListener('click', () => this.gridAddBelow())
|
||||||
|
|
||||||
|
this.elGridRemove.addEventListener('click', () => this.gridRemove())
|
||||||
|
this.elGridRemoveMoveLeft.addEventListener('click', () => this.gridRemoveMoveLeft())
|
||||||
|
this.elGridRemoveMoveUp.addEventListener('click', () => this.gridRemoveMoveUp())
|
||||||
|
|
||||||
|
this.elGridAddColumnLeft.addEventListener('click', () => this.gridAddColumnLeft())
|
||||||
|
this.elGridAddColumnAbove.addEventListener('click', () => this.gridAddColumnAbove())
|
||||||
|
this.elGridRemoveColumn.addEventListener('click', () => this.gridRemoveColumn())
|
||||||
|
this.elGridRemoveRow.addEventListener('click', () => this.gridRemoveRow())
|
||||||
|
|
||||||
|
this.render()
|
||||||
|
this.fetchData() // Retrieve one as fast as possible since setInterval doesn't run before an interval.
|
||||||
|
setInterval(() => this.fetchData(), 1000)
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
render() {// {{{
|
||||||
|
const widgets = []
|
||||||
|
this.widgets = new Map()
|
||||||
|
this.occupiedCells = new Map()
|
||||||
|
|
||||||
|
let maxX = 0
|
||||||
|
let maxY = 0
|
||||||
|
|
||||||
|
for (const wd of this.data.Widgets) {
|
||||||
|
if (!this.widgets.has(wd.DatapointID))
|
||||||
|
this.widgets.set(wd.DatapointID, [])
|
||||||
|
// widgetRefs modifies the array in the map directly.
|
||||||
|
const widgetRefs = this.widgets.get(wd.DatapointID)
|
||||||
|
const widget = SmonWidget.create(wd.Type, wd)
|
||||||
|
widget.addEventListener('click', () => this.editCell(widget))
|
||||||
|
widgetRefs.push(widget)
|
||||||
|
widgets.push(widget)
|
||||||
|
|
||||||
|
maxX = Math.max(maxX, wd.X)
|
||||||
|
maxY = Math.max(maxY, wd.Y)
|
||||||
|
|
||||||
|
this.occupiedCells.set(`${wd.X}x${wd.Y}`, true)
|
||||||
|
for (let x = 0; x <= wd.SpanX; x++)
|
||||||
|
this.occupiedCells.set(`${wd.X + x}x${wd.Y}`, true)
|
||||||
|
for (let y = 0; y <= wd.SpanY; y++)
|
||||||
|
this.occupiedCells.set(`${wd.X}x${wd.Y + y}`, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
maxX += this.addColumns
|
||||||
|
maxY += this.addRows
|
||||||
|
|
||||||
|
// Empty elements are created to fill up the empty cells in the grid.
|
||||||
|
// This is needed as placeholders for grid and dragdrop targets when editing.
|
||||||
|
for (let x = 1; x <= maxX; x++)
|
||||||
|
for (let y = 1; y <= maxY; y++) {
|
||||||
|
if (this.occupiedCells.has(`${x}x${y}`))
|
||||||
|
continue
|
||||||
|
|
||||||
|
const empty = new SmonWidgetEmpty({ X: x, Y: y })
|
||||||
|
empty.addEventListener('click', () => this.editCell(empty))
|
||||||
|
widgets.push(empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.elUpdateDashboard.disabled = !this.widgetsEdited
|
||||||
|
|
||||||
|
this.elGrid.replaceChildren(...widgets)
|
||||||
|
}// }}}
|
||||||
|
error(msg) {// {{{
|
||||||
|
this.elError.innerText = msg
|
||||||
|
if (msg === '')
|
||||||
|
this.elError.classList.remove('show')
|
||||||
|
else
|
||||||
|
this.elError.classList.add('show')
|
||||||
|
}// }}}
|
||||||
|
async editUpdate() {// {{{
|
||||||
|
const changes = await this.editedWidget.editUpdate()
|
||||||
|
this.widgetsEdited = true
|
||||||
|
|
||||||
|
let replacedWidget = false
|
||||||
|
if (changes.add) {
|
||||||
|
const coords = `${changes.add.data.X}x${changes.add.data.Y}`
|
||||||
|
|
||||||
|
for (const w of this.data.Widgets) {
|
||||||
|
if (w.X === changes.add.data.X && w.Y === changes.add.data.Y) {
|
||||||
|
alert(`A widget already exist at ${coords}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!replacedWidget)
|
||||||
|
this.data.Widgets.push(changes.add.data)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.elEdit.close()
|
||||||
|
this.editedWidget = null
|
||||||
|
this.render()
|
||||||
|
}// }}}
|
||||||
|
editCell(el) {// {{{
|
||||||
|
if (!this.classList.contains('edit'))
|
||||||
|
return
|
||||||
|
|
||||||
|
this.editedWidget = el
|
||||||
|
const components = el.edit()
|
||||||
|
this.elEditComponents.replaceChildren(...components)
|
||||||
|
this.elEdit.showModal()
|
||||||
|
}// }}}
|
||||||
|
async fetchData() {// {{{
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/dashboard/values`)
|
||||||
|
const json = await res.json()
|
||||||
|
|
||||||
|
if (!json.OK)
|
||||||
|
throw new Error(json.Error)
|
||||||
|
|
||||||
|
for (const dp of json.Values) {
|
||||||
|
const widgets = this.widgets.get(dp.ID) || []
|
||||||
|
for (const w of widgets) {
|
||||||
|
w.setValue(dp.Value, dp.Valid)
|
||||||
|
w.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.error('')
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
this.error(e.message)
|
||||||
|
|
||||||
|
// All widgets are set in a NULL state to visualize the error.
|
||||||
|
const allDpIDs = this.widgets.keys()
|
||||||
|
for (const id of allDpIDs) {
|
||||||
|
const widgets = this.widgets.get(id) || []
|
||||||
|
for (const w of widgets) {
|
||||||
|
w.setValue(null, false)
|
||||||
|
w.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}// }}}
|
||||||
|
async updateDashboard() {// {{{
|
||||||
|
try {
|
||||||
|
const res = await fetch('/dashboard/update', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify(this.data),
|
||||||
|
})
|
||||||
|
const json = await res.json()
|
||||||
|
if (!json.OK)
|
||||||
|
throw new Error(json.Error)
|
||||||
|
|
||||||
|
this.widgetsEdited = false
|
||||||
|
this.render()
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e)
|
||||||
|
alert(e.message)
|
||||||
|
}
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
gridEdit() {// {{{
|
||||||
|
this.widgetsEdited = true
|
||||||
|
this.elEdit.close()
|
||||||
|
this.editedWidget = null
|
||||||
|
this.render()
|
||||||
|
}// }}}
|
||||||
|
gridAddLeft() {// {{{
|
||||||
|
const fromX = this.editedWidget.data.X
|
||||||
|
const fromY = this.editedWidget.data.Y
|
||||||
|
for (const w of this.data.Widgets)
|
||||||
|
if (w.X >= fromX && w.Y == fromY)
|
||||||
|
w.X++
|
||||||
|
|
||||||
|
this.gridEdit()
|
||||||
|
}// }}}
|
||||||
|
gridAddRight() {// {{{
|
||||||
|
const fromX = this.editedWidget.data.X
|
||||||
|
const fromY = this.editedWidget.data.Y
|
||||||
|
for (const w of this.data.Widgets)
|
||||||
|
if (w.X > fromX && w.Y == fromY)
|
||||||
|
w.X++
|
||||||
|
|
||||||
|
this.gridEdit()
|
||||||
|
}// }}}
|
||||||
|
gridAddAbove() {// {{{
|
||||||
|
const fromX = this.editedWidget.data.X
|
||||||
|
const fromY = this.editedWidget.data.Y
|
||||||
|
for (const w of this.data.Widgets)
|
||||||
|
if (w.X == fromX && w.Y >= fromY)
|
||||||
|
w.Y++
|
||||||
|
|
||||||
|
this.gridEdit()
|
||||||
|
}// }}}
|
||||||
|
gridAddBelow() {// {{{
|
||||||
|
const fromX = this.editedWidget.data.X
|
||||||
|
const fromY = this.editedWidget.data.Y
|
||||||
|
for (const w of this.data.Widgets)
|
||||||
|
if (w.X == fromX && w.Y > fromY)
|
||||||
|
w.Y++
|
||||||
|
|
||||||
|
this.gridEdit()
|
||||||
|
}// }}}
|
||||||
|
gridAddColumnLeft() {// {{{
|
||||||
|
const fromX = this.editedWidget.data.X
|
||||||
|
for (const w of this.data.Widgets)
|
||||||
|
if (w.X >= fromX)
|
||||||
|
w.X++
|
||||||
|
|
||||||
|
this.gridEdit()
|
||||||
|
}// }}}
|
||||||
|
gridAddColumnAbove() {// {{{
|
||||||
|
const fromY = this.editedWidget.data.Y
|
||||||
|
for (const w of this.data.Widgets)
|
||||||
|
if (w.Y >= fromY)
|
||||||
|
w.Y++
|
||||||
|
|
||||||
|
this.gridEdit()
|
||||||
|
}// }}}
|
||||||
|
gridRemoveColumn() {// {{{
|
||||||
|
// Keep all widgets that's not on the edited widget's X coordinate.
|
||||||
|
this.data.Widgets = this.data.Widgets.filter(w =>
|
||||||
|
w.X != this.editedWidget.data.X
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const w of this.data.Widgets)
|
||||||
|
if (w.X >= this.editedWidget.data.X)
|
||||||
|
w.X--
|
||||||
|
this.gridEdit()
|
||||||
|
}// }}}
|
||||||
|
gridRemoveRow() {// {{{
|
||||||
|
// Keep all widgets that's not on the edited widget's X coordinate.
|
||||||
|
this.data.Widgets = this.data.Widgets.filter(w =>
|
||||||
|
w.Y != this.editedWidget.data.Y
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const w of this.data.Widgets)
|
||||||
|
if (w.Y >= this.editedWidget.data.Y)
|
||||||
|
w.Y--
|
||||||
|
this.gridEdit()
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
gridRemove() {// {{{
|
||||||
|
const fromX = this.editedWidget.data.X
|
||||||
|
const fromY = this.editedWidget.data.Y
|
||||||
|
|
||||||
|
this.data.Widgets = this.data.Widgets.filter(w =>
|
||||||
|
w.X != fromX || w.Y != fromY
|
||||||
|
)
|
||||||
|
this.gridEdit()
|
||||||
|
}// }}}
|
||||||
|
gridRemoveMoveLeft() {// {{{
|
||||||
|
const fromX = this.editedWidget.data.X
|
||||||
|
const fromY = this.editedWidget.data.Y
|
||||||
|
|
||||||
|
this.data.Widgets = this.data.Widgets.filter(w =>
|
||||||
|
w.X != fromX || w.Y != fromY
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const w of this.data.Widgets)
|
||||||
|
if (w.X > fromX && w.Y == fromY)
|
||||||
|
w.X--
|
||||||
|
|
||||||
|
this.gridEdit()
|
||||||
|
}// }}}
|
||||||
|
gridRemoveMoveUp() {// {{{
|
||||||
|
const fromX = this.editedWidget.data.X
|
||||||
|
const fromY = this.editedWidget.data.Y
|
||||||
|
|
||||||
|
this.data.Widgets = this.data.Widgets.filter(w =>
|
||||||
|
w.X != fromX || w.Y != fromY
|
||||||
|
)
|
||||||
|
|
||||||
|
for (const w of this.data.Widgets)
|
||||||
|
if (w.X == fromX && w.Y > fromY)
|
||||||
|
w.Y--
|
||||||
|
|
||||||
|
this.gridEdit()
|
||||||
|
}// }}}
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
class SmonWidget extends CustomHTMLElement {// {{{
|
||||||
|
static create(t, data) {// {{{
|
||||||
|
data.Type = t
|
||||||
|
switch (t) {
|
||||||
|
case WIDGET_SPACE: return new SmonWidgetSpace(data)
|
||||||
|
case WIDGET_LABEL: return new SmonWidgetLabel(data)
|
||||||
|
case WIDGET_ONOFF: return new SmonWidgetOnOff(data)
|
||||||
|
default:
|
||||||
|
alert(`Unknown widget type: ${t} (${typeof t})`)
|
||||||
|
}
|
||||||
|
}// }}}
|
||||||
|
constructor(data) {// {{{
|
||||||
|
super(true)
|
||||||
|
|
||||||
|
this.data = data
|
||||||
|
this.value = null
|
||||||
|
this.editDialog = null
|
||||||
|
|
||||||
|
if (!this.data.Attributes)
|
||||||
|
this.data.Attributes = {}
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
render() {// {{{
|
||||||
|
this.style.gridColumn = this.data.X
|
||||||
|
this.style.gridRow = this.data.Y
|
||||||
|
|
||||||
|
if (this.data.SpanX > 0)
|
||||||
|
this.style.gridColumn = `${this.data.X} / ${this.data.X + this.data.SpanX + 1}`
|
||||||
|
|
||||||
|
if (this.data.SpanY > 0)
|
||||||
|
this.style.gridRow = `${this.data.Y} / ${this.data.Y + this.data.SpanY + 1}`
|
||||||
|
|
||||||
|
if (this.data.Attributes.BorderTop) {
|
||||||
|
if (this.data.Attributes.Color)
|
||||||
|
this.style.borderTop = `1px solid ${this.data.Attributes.Color}`
|
||||||
|
else
|
||||||
|
this.style.borderTop = `1px solid currentColor`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.data.Attributes.BorderBottom) {
|
||||||
|
if (this.data.Attributes.Color)
|
||||||
|
this.style.borderBottom = `1px solid ${this.data.Attributes.Color}`
|
||||||
|
else
|
||||||
|
this.style.borderBottom = `1px solid currentColor`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.data.Attributes.BorderLeft) {
|
||||||
|
if (this.data.Attributes.Color)
|
||||||
|
this.style.borderLeft = `1px solid ${this.data.Attributes.Color}`
|
||||||
|
else
|
||||||
|
this.style.borderLeft = `1px solid currentColor`
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.data.Attributes.BorderRight) {
|
||||||
|
if (this.data.Attributes.Color)
|
||||||
|
this.style.borderRight = `1px solid ${this.data.Attributes.Color}`
|
||||||
|
else
|
||||||
|
this.style.borderRight = `1px solid currentColor`
|
||||||
|
}
|
||||||
|
}// }}}
|
||||||
|
setValue(v, valid) {// {{{
|
||||||
|
this.value = valid ? v : null
|
||||||
|
}// }}}
|
||||||
|
edit() {// {{{
|
||||||
|
if (this.editDialog !== null)
|
||||||
|
return
|
||||||
|
|
||||||
|
const components = this.commonEditWidgets()
|
||||||
|
components.push(...this.editWidget())
|
||||||
|
return components
|
||||||
|
}// }}}
|
||||||
|
commonEditWidgets() {// {{{
|
||||||
|
this.editDiv = document.createElement('div')
|
||||||
|
this.editDiv.innerHTML = `
|
||||||
|
<div style="display: flex; gap: 48px;">
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: bold;">Borders</div>
|
||||||
|
<div style="margin-top: 8px; user-select: none; display: grid; width: min-content;">
|
||||||
|
<label style="grid-column: 2; grid-row: 1;"><input type="checkbox" name="top"></label>
|
||||||
|
<label style="grid-column: 3; grid-row: 2;"><input type="checkbox" name="right"></label>
|
||||||
|
<label style="grid-column: 2; grid-row: 3;"><input type="checkbox" name="bottom"></label>
|
||||||
|
<label style="grid-column: 1; grid-row: 2;"><input type="checkbox" name="left"></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: bold;">Spanning</div>
|
||||||
|
<div style="margin-top: 8px; display: grid; grid-template-columns: min-content 48px; grid-gap: 4px 8px; align-items: center;">
|
||||||
|
<div>X</div>
|
||||||
|
<input type="number" name="span-x">
|
||||||
|
|
||||||
|
<div>Y</div>
|
||||||
|
<input type="number" name="span-y">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div style="font-weight: bold;">Font</div>
|
||||||
|
<div style="margin-top: 8px; display: grid; grid-template-columns: min-content 96px; grid-gap: 4px 8px; align-items: center;">
|
||||||
|
<div>Size</div>
|
||||||
|
<input type="text" name="font-size">
|
||||||
|
|
||||||
|
<div>Color</div>
|
||||||
|
<div style="display: flex; gap: 8px">
|
||||||
|
<input type="color" name="color">
|
||||||
|
<input type="checkbox" name="use-color">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
this.editDiv.style.marginBottom = '16px'
|
||||||
|
|
||||||
|
this.elBorderTop = this.editDiv.querySelector('input[name="top"]')
|
||||||
|
this.elBorderBottom = this.editDiv.querySelector('input[name="bottom"]')
|
||||||
|
this.elBorderLeft = this.editDiv.querySelector('input[name="left"]')
|
||||||
|
this.elBorderRight = this.editDiv.querySelector('input[name="right"]')
|
||||||
|
this.elSpanX = this.editDiv.querySelector('input[name="span-x"]')
|
||||||
|
this.elSpanY = this.editDiv.querySelector('input[name="span-y"]')
|
||||||
|
this.elFontSize = this.editDiv.querySelector('input[name="font-size"]')
|
||||||
|
this.elUseColor = this.editDiv.querySelector('input[name="use-color"]')
|
||||||
|
this.elColor = this.editDiv.querySelector('input[name="color"]')
|
||||||
|
this.elColor.addEventListener('input', () => this.elUseColor.checked = true)
|
||||||
|
|
||||||
|
this.elBorderTop.checked = this.data.Attributes.BorderTop
|
||||||
|
this.elBorderBottom.checked = this.data.Attributes.BorderBottom
|
||||||
|
this.elBorderLeft.checked = this.data.Attributes.BorderLeft
|
||||||
|
this.elBorderRight.checked = this.data.Attributes.BorderRight
|
||||||
|
|
||||||
|
this.elSpanX.value = this.data.SpanX
|
||||||
|
this.elSpanY.value = this.data.SpanY
|
||||||
|
|
||||||
|
this.elFontSize.value = this.data.Attributes.Size || ''
|
||||||
|
this.elUseColor.checked = this.data.Attributes.Color ? true : false
|
||||||
|
this.elColor.value = this.data.Attributes.Color || ''
|
||||||
|
|
||||||
|
return [this.editDiv]
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
// edit dialog wants to update the widget.
|
||||||
|
async editUpdate() {// {{{
|
||||||
|
this.data.Attributes.BorderTop = this.elBorderTop.checked ? 'true' : ''
|
||||||
|
this.data.Attributes.BorderBottom = this.elBorderBottom.checked ? 'true' : ''
|
||||||
|
this.data.Attributes.BorderLeft = this.elBorderLeft.checked ? 'true' : ''
|
||||||
|
this.data.Attributes.BorderRight = this.elBorderRight.checked ? 'true' : ''
|
||||||
|
|
||||||
|
this.data.SpanX = parseInt(this.elSpanX.value)
|
||||||
|
this.data.SpanY = parseInt(this.elSpanY.value)
|
||||||
|
|
||||||
|
this.data.Attributes.Size = this.elFontSize.value
|
||||||
|
this.data.Attributes.Color = this.elUseColor.checked ? this.elColor.value : ''
|
||||||
|
|
||||||
|
return await this.updateWidget()
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
// Many widgets can have labels.
|
||||||
|
// This function applies common attributes like size and color.
|
||||||
|
applyLabelAttributes(el) {// {{{
|
||||||
|
if (this.data.Attributes?.Size)
|
||||||
|
el.style.fontSize = this.data.Attributes.Size
|
||||||
|
|
||||||
|
if (this.data.Attributes?.Color)
|
||||||
|
el.style.color = this.data.Attributes.Color
|
||||||
|
|
||||||
|
if (this.data.Attributes?.Underline)
|
||||||
|
if (this.data.Attributes?.Color)
|
||||||
|
el.style.borderBottom = `1px solid ${this.data.Attributes.Color}`
|
||||||
|
else
|
||||||
|
el.style.borderBottom = `1px solid currentColor`
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
}// }}}
|
||||||
|
class SmonWidgetEmpty extends SmonWidget {// {{{
|
||||||
|
static {// {{{
|
||||||
|
this.tmpl = document.createElement('template')
|
||||||
|
this.tmpl.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
min-width: 16px;
|
||||||
|
min-height: 16px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
`
|
||||||
|
}// }}}
|
||||||
|
constructor(data) {// {{{
|
||||||
|
super(data)
|
||||||
|
this.render()
|
||||||
|
}// }}}
|
||||||
|
render() {// {{{
|
||||||
|
super.render()
|
||||||
|
}// }}}
|
||||||
|
editWidget() {// {{{
|
||||||
|
this.editDiv = document.createElement('div')
|
||||||
|
this.editDiv.innerHTML = `
|
||||||
|
<div style="font-weight: bold;">Add widget</div>
|
||||||
|
<select style="width: 100%; padding: 4px 4px">
|
||||||
|
<option value="0">Space</option>
|
||||||
|
<option value="1">Label</option>
|
||||||
|
<option value="2">On / off</option>
|
||||||
|
</select>
|
||||||
|
`
|
||||||
|
return [this.editDiv]
|
||||||
|
}// }}}
|
||||||
|
async updateWidget() {// {{{
|
||||||
|
const t = parseInt(this.editDiv.querySelector('select').value)
|
||||||
|
const widget = SmonWidget.create(t, JSON.parse(JSON.stringify(this.data)))
|
||||||
|
return { add: widget }
|
||||||
|
}// }}}
|
||||||
|
}// }}}
|
||||||
|
class SmonWidgetSpace extends SmonWidget {// {{{
|
||||||
|
static {// {{{
|
||||||
|
this.tmpl = document.createElement('template')
|
||||||
|
this.tmpl.innerHTML = `
|
||||||
|
<div data-el="spacer"></div>
|
||||||
|
`
|
||||||
|
}// }}}
|
||||||
|
constructor(data) {// {{{
|
||||||
|
super(data)
|
||||||
|
this.render()
|
||||||
|
}// }}}
|
||||||
|
render() {// {{{
|
||||||
|
super.render()
|
||||||
|
|
||||||
|
this.elSpacer.style.minWidth = this.data.Attributes.Width || '16px'
|
||||||
|
this.elSpacer.style.minHeight = this.data.Attributes.Height || '16px'
|
||||||
|
}// }}}
|
||||||
|
editWidget() {// {{{
|
||||||
|
this.editDiv = document.createElement('div')
|
||||||
|
this.editDiv.innerHTML = `
|
||||||
|
<div style="margin-top: 16px; font-weight: bold;">Spacing</div>
|
||||||
|
<div style="margin-top: 8px; display: grid; grid-template-columns: min-content 128px; align-items: center; grid-gap: 4px 8px;">
|
||||||
|
<div>Width</div>
|
||||||
|
<input type="text" name="width">
|
||||||
|
|
||||||
|
<div>Height</div>
|
||||||
|
<input type="text" name="height">
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
|
||||||
|
this.elWidth = this.editDiv.querySelector('[name="width"]')
|
||||||
|
this.elHeight = this.editDiv.querySelector('[name="height"]')
|
||||||
|
|
||||||
|
this.elWidth.value = this.data.Attributes?.Width || ''
|
||||||
|
this.elHeight.value = this.data.Attributes?.Height || ''
|
||||||
|
|
||||||
|
return [this.editDiv]
|
||||||
|
}// }}}
|
||||||
|
async updateWidget() {// {{{
|
||||||
|
this.data.Attributes.Width = this.elWidth.value
|
||||||
|
this.data.Attributes.Height = this.elHeight.value
|
||||||
|
return {}
|
||||||
|
}// }}}
|
||||||
|
}// }}}
|
||||||
|
class SmonWidgetLabel extends SmonWidget {// {{{
|
||||||
|
static {// {{{
|
||||||
|
this.tmpl = document.createElement('template')
|
||||||
|
this.tmpl.innerHTML = `
|
||||||
|
<style>
|
||||||
|
.el-label {
|
||||||
|
font-weight: bold;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<div data-el="label"></div>
|
||||||
|
`
|
||||||
|
}// }}}
|
||||||
|
constructor(data) {// {{{
|
||||||
|
super(data)
|
||||||
|
this.render()
|
||||||
|
}// }}}
|
||||||
|
render() {// {{{
|
||||||
|
super.render()
|
||||||
|
this.elLabel.innerText = this.data.Attributes?.Label || '[set label]'
|
||||||
|
this.applyLabelAttributes(this.elLabel)
|
||||||
|
}// }}}
|
||||||
|
editWidget() {// {{{
|
||||||
|
this.editDiv = document.createElement('div')
|
||||||
|
this.editDiv.innerHTML = `
|
||||||
|
<div style="font-weight: bold">Label</div>
|
||||||
|
<input type="text">
|
||||||
|
`
|
||||||
|
this.editDiv.querySelector('input').value = this.data.Attributes.Label || '[set label]'
|
||||||
|
return [this.editDiv]
|
||||||
|
}// }}}
|
||||||
|
async updateWidget() {// {{{
|
||||||
|
this.data.Attributes.Label = this.editDiv.querySelector('input').value
|
||||||
|
return {}
|
||||||
|
}// }}}
|
||||||
|
}// }}}
|
||||||
|
class SmonWidgetOnOff extends SmonWidget {// {{{
|
||||||
|
static {// {{{
|
||||||
|
this.tmpl = document.createElement('template')
|
||||||
|
this.tmpl.innerHTML = `
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: min-content 1fr;
|
||||||
|
align-items: center;
|
||||||
|
grid-gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
</style>
|
||||||
|
<img data-el="status">
|
||||||
|
<div data-el="label"></div>
|
||||||
|
`
|
||||||
|
}// }}}
|
||||||
|
constructor(data) {// {{{
|
||||||
|
super(data)
|
||||||
|
this.render()
|
||||||
|
}// }}}
|
||||||
|
render() {// {{{
|
||||||
|
super.render()
|
||||||
|
|
||||||
|
let img
|
||||||
|
switch (this.value) {
|
||||||
|
case 0:
|
||||||
|
case 'OFF':
|
||||||
|
img = 'widget_light_red.svg'
|
||||||
|
break
|
||||||
|
|
||||||
|
case 1:
|
||||||
|
case 'ON':
|
||||||
|
img = 'widget_light_green.svg'
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
img = 'widget_light_off.svg'
|
||||||
|
}
|
||||||
|
|
||||||
|
this.elStatus.setAttribute('src', `/images/${_VERSION}/${img}`)
|
||||||
|
this.elLabel.innerText = this.data.Attributes.Label || '[set label]'
|
||||||
|
this.applyLabelAttributes(this.elLabel)
|
||||||
|
}// }}}
|
||||||
|
editWidget() {// {{{
|
||||||
|
this.editDiv = document.createElement('div')
|
||||||
|
this.editDiv.innerHTML = `
|
||||||
|
<div style="font-weight: bold;">Label</div>
|
||||||
|
<input type="text">
|
||||||
|
`
|
||||||
|
this.editDiv.querySelector('input').value = this.data.Attributes.Label || '[set label]'
|
||||||
|
return [this.editDiv]
|
||||||
|
}// }}}
|
||||||
|
async updateWidget() {// {{{
|
||||||
|
this.data.Attributes.Label = this.editDiv.querySelector('input').value
|
||||||
|
return {}
|
||||||
|
}// }}}
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
customElements.define("smon-widget", SmonWidget)
|
||||||
|
customElements.define("smon-widget-empty", SmonWidgetEmpty)
|
||||||
|
customElements.define("smon-widget-space", SmonWidgetSpace)
|
||||||
|
customElements.define("smon-widget-label", SmonWidgetLabel)
|
||||||
|
customElements.define("smon-widget-onoff", SmonWidgetOnOff)
|
||||||
|
customElements.define("smon-dashboard", SmonDashboard)
|
||||||
51
static/js/lib/custom_html_element.mjs
Normal file
51
static/js/lib/custom_html_element.mjs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
/* Use data-el or data-field attribute.
|
||||||
|
* Element with data-el="hum-ding" is accessible as this.elHumDing and fields with
|
||||||
|
* data-field="long-dong" as this.fieldLongDong.
|
||||||
|
*
|
||||||
|
* All field values can be retrieved with fieldValues() and uses the data-field attribute
|
||||||
|
* as LongDong as key.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class CustomHTMLElement extends HTMLElement {
|
||||||
|
constructor(useShadow) {// {{{
|
||||||
|
super()
|
||||||
|
|
||||||
|
this._fields = new Map()
|
||||||
|
|
||||||
|
const workOn = useShadow ? this.attachShadow({ mode: 'open' }) : this
|
||||||
|
workOn.appendChild(this.constructor.tmpl.content.cloneNode(true))
|
||||||
|
workOn.querySelectorAll('*').forEach(el => {
|
||||||
|
const field = el.dataset.field
|
||||||
|
if (field !== undefined) {
|
||||||
|
const fieldName = this.toElementName('field', field)
|
||||||
|
this[fieldName] = el
|
||||||
|
this._fields.set(this.toElementName('', field), el)
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = el.dataset.el
|
||||||
|
if (name !== undefined) {
|
||||||
|
const elName = this.toElementName('el', name)
|
||||||
|
this[elName] = el
|
||||||
|
el.classList.add('el-' + name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}// }}}
|
||||||
|
allFields() {// {{{
|
||||||
|
return this._fields
|
||||||
|
}// }}}
|
||||||
|
fieldValues() {// {{{
|
||||||
|
const state = {}
|
||||||
|
for (const [name, field] of this._fields) {
|
||||||
|
if (field.tagName.toLowerCase() == 'input' && field.getAttribute('type').toLowerCase() == 'checkbox')
|
||||||
|
state[name] = field.checked
|
||||||
|
else
|
||||||
|
state[name] = field.value
|
||||||
|
|
||||||
|
}
|
||||||
|
return state
|
||||||
|
}// }}}
|
||||||
|
toElementName(prefix, str) {// {{{
|
||||||
|
str = prefix + '-' + str
|
||||||
|
return str.replace(/-(id|[a-z])/g, match => match.toUpperCase().replace('-', ''))
|
||||||
|
}// }}}
|
||||||
|
}
|
||||||
|
|
@ -6,6 +6,9 @@
|
||||||
{{ template "fonts" }}
|
{{ template "fonts" }}
|
||||||
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/{{ .CONFIG.THEME }}/main.css">
|
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/{{ .CONFIG.THEME }}/main.css">
|
||||||
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/{{ .CONFIG.THEME }}/{{ .CONFIG.THEME }}.css">
|
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/{{ .CONFIG.THEME }}/{{ .CONFIG.THEME }}.css">
|
||||||
|
<script>
|
||||||
|
globalThis._VERSION = {{ .VERSION }}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="page-error" class="{{ if ne .ERROR "" }}show{{ end }}">
|
<div id="page-error" class="{{ if ne .ERROR "" }}show{{ end }}">
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,19 @@
|
||||||
{{ define "page" }}
|
{{ define "page" }}
|
||||||
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/{{ .CONFIG.THEME }}/index.css">
|
<link rel="stylesheet" type="text/css" href="/css/{{ .VERSION }}/{{ .CONFIG.THEME }}/index.css">
|
||||||
|
|
||||||
<div style="float: left;">
|
<script type="module" defer>
|
||||||
<img src="/images/{{ .VERSION }}/{{ .CONFIG.THEME }}/logo.svg" style="width: 64px; margin-right: 32px;">
|
import { } from '/js/{{ .VERSION }}/dashboard.mjs'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div style="display: grid; grid-template-columns: repeat(2, min-content); align-items: end; grid-gap: 8px;">
|
||||||
|
<img src="/images/{{ .VERSION }}/{{ .CONFIG.THEME }}/logo.svg" style="width: 64px; margin-right: 16px;">
|
||||||
|
<div>
|
||||||
|
<h1 style="margin-bottom: 0px">Smon</h1>
|
||||||
|
<h2 style="margin-top: 0px">{{ .VERSION }}</h2>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="float: left;">
|
<smon-dashboard style="margin-top: 32px">
|
||||||
<h1>SMon</h1>
|
<script type="application/json">{{ .Data.Dashboard }}</script>
|
||||||
<h2>{{ .VERSION }}</h2>
|
</smon-dashboard>
|
||||||
|
|
||||||
</div>
|
|
||||||
{{ end }}
|
{{ end }}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue