Added start of editable dashboard
This commit is contained in:
parent
1af3f59b1a
commit
c3fe5951d4
8 changed files with 502 additions and 92 deletions
34
dashboard.go
34
dashboard.go
|
|
@ -1,20 +1,50 @@
|
||||||
package main
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
// External
|
||||||
|
werr "git.ahall.se/go/wrappederror"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
|
||||||
|
// Standard
|
||||||
|
"context"
|
||||||
|
)
|
||||||
|
|
||||||
type WidgetType int
|
type WidgetType int
|
||||||
|
|
||||||
const (
|
const (
|
||||||
WidgetLabel WidgetType = iota
|
WIDGET_LABEL WidgetType = iota
|
||||||
WidgetOnOff
|
WIDGET_ONOFF
|
||||||
)
|
)
|
||||||
|
|
||||||
type Widget struct {
|
type Widget struct {
|
||||||
Type WidgetType
|
Type WidgetType
|
||||||
X int
|
X int
|
||||||
Y int
|
Y int
|
||||||
|
SpanX int
|
||||||
|
SpanY int
|
||||||
DatapointID int
|
DatapointID int
|
||||||
Attributes map[string]string
|
Attributes map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
type Dashboard struct {
|
type Dashboard struct {
|
||||||
|
ID int
|
||||||
|
Name string
|
||||||
Widgets []Widget
|
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
|
||||||
|
}
|
||||||
|
|
|
||||||
71
datapoint.go
71
datapoint.go
|
|
@ -30,11 +30,32 @@ type Datapoint struct {
|
||||||
LastValue time.Time `db:"last_value"`
|
LastValue time.Time `db:"last_value"`
|
||||||
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"`
|
||||||
|
|
@ -370,3 +391,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
|
||||||
|
|
|
||||||
46
main.go
46
main.go
|
|
@ -160,6 +160,8 @@ 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("/dashboard/values", actionDashboardValues)
|
||||||
|
|
||||||
go nodataLoop()
|
go nodataLoop()
|
||||||
|
|
||||||
smonConfig, err = SmonConfigInit()
|
smonConfig, err = SmonConfigInit()
|
||||||
|
|
@ -406,18 +408,34 @@ func pageIndex(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
CONFIG: smonConfig.Settings,
|
CONFIG: smonConfig.Settings,
|
||||||
}
|
}
|
||||||
|
|
||||||
data := make(map[string]any)
|
|
||||||
data["Dashboard"] = Dashboard{
|
|
||||||
Widgets: []Widget{
|
|
||||||
{Type: 0, X: 1, Y: 1, Attributes: map[string]string{"Label":"Services"}},
|
|
||||||
|
|
||||||
{Type: 1, X: 1, Y: 2, DatapointID: 13, Attributes: map[string]string{"Label":"Borg"}},
|
dashboard, err := DashboardGet("start")
|
||||||
{Type: 1, X: 1, Y: 3, DatapointID: 13, Attributes: map[string]string{"Label":"Databasus"}},
|
if err != nil {
|
||||||
},
|
logger.Error("dashboard", "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data := make(map[string]any)
|
||||||
|
data["Dashboard"] = dashboard
|
||||||
page.Data = data
|
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 actionAreaNew(w http.ResponseWriter, r *http.Request) { // {{{
|
func actionAreaNew(w http.ResponseWriter, r *http.Request) { // {{{
|
||||||
name := r.PathValue("name")
|
name := r.PathValue("name")
|
||||||
|
|
@ -429,7 +447,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")
|
||||||
|
|
@ -448,7 +465,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")
|
||||||
|
|
@ -466,7 +482,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) { // {{{
|
||||||
|
|
@ -486,7 +501,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")
|
||||||
|
|
@ -505,7 +519,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")
|
||||||
|
|
@ -523,7 +536,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) { // {{{
|
||||||
|
|
@ -588,7 +600,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")
|
||||||
|
|
@ -606,8 +617,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")
|
||||||
|
|
@ -625,8 +634,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) { // {{{
|
||||||
|
|
@ -653,7 +660,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")
|
||||||
|
|
@ -710,7 +716,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")
|
||||||
|
|
@ -843,7 +848,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")
|
||||||
|
|
|
||||||
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")
|
||||||
|
);
|
||||||
|
|
@ -7,8 +7,8 @@
|
||||||
viewBox="0 0 8.466666 8.4666661"
|
viewBox="0 0 8.466666 8.4666661"
|
||||||
version="1.1"
|
version="1.1"
|
||||||
id="svg1"
|
id="svg1"
|
||||||
inkscape:version="1.4.2 (ebf0e94, 2025-05-08)"
|
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||||
sodipodi:docname="widget_light_off.svg"
|
sodipodi:docname="widget_light_red.svg"
|
||||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|
@ -25,11 +25,11 @@
|
||||||
inkscape:document-units="px"
|
inkscape:document-units="px"
|
||||||
showborder="false"
|
showborder="false"
|
||||||
inkscape:zoom="32"
|
inkscape:zoom="32"
|
||||||
inkscape:cx="18.171875"
|
inkscape:cx="18.1875"
|
||||||
inkscape:cy="16.25"
|
inkscape:cy="16.265625"
|
||||||
inkscape:window-width="1916"
|
inkscape:window-width="1093"
|
||||||
inkscape:window-height="1161"
|
inkscape:window-height="1401"
|
||||||
inkscape:window-x="0"
|
inkscape:window-x="2560"
|
||||||
inkscape:window-y="0"
|
inkscape:window-y="0"
|
||||||
inkscape:window-maximized="1"
|
inkscape:window-maximized="1"
|
||||||
inkscape:current-layer="layer1" />
|
inkscape:current-layer="layer1" />
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
inkscape:groupmode="layer"
|
inkscape:groupmode="layer"
|
||||||
id="layer1">
|
id="layer1">
|
||||||
<circle
|
<circle
|
||||||
style="fill:#d41400;stroke:none;stroke-width:1.00012;fill-opacity:1"
|
style="fill:#ff2a2a;stroke:none;stroke-width:0.79399899;fill-opacity:1"
|
||||||
id="circle1"
|
id="circle1"
|
||||||
cx="4.2333331"
|
cx="4.2333331"
|
||||||
cy="4.2333331"
|
cy="4.2333331"
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.4 KiB |
|
|
@ -1,91 +1,373 @@
|
||||||
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
|
||||||
|
|
||||||
export class SmonDashboard extends CustomHTMLElement {
|
export class SmonDashboard extends CustomHTMLElement {// {{{
|
||||||
static {
|
static {// {{{
|
||||||
this.tmpl = document.createElement('template')
|
this.tmpl = document.createElement('template')
|
||||||
this.tmpl.innerHTML = `
|
this.tmpl.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
:host {
|
:host {
|
||||||
|
--edit-border: 1px solid #888;
|
||||||
|
|
||||||
display: grid;
|
display: grid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host(.edit) {
|
||||||
|
.el-grid {
|
||||||
|
grid-gap: unset;
|
||||||
|
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);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
& > .empty {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.el-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-auto-flow: column;
|
grid-auto-flow: column;
|
||||||
grid-auto-columns: min-content;
|
grid-auto-columns: min-content;
|
||||||
grid-gap: 8px 16px;
|
grid-gap: 8px 16px;
|
||||||
|
width: min-content;
|
||||||
|
|
||||||
|
.empty {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.only-on-edit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
width: min-content;
|
||||||
|
white-space: nowrap;
|
||||||
|
padding: 4px 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog {
|
||||||
|
input[type="text"] {
|
||||||
|
padding: 4px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
margin-bottom: unset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</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 data-el="grid">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<dialog data-el="edit">
|
||||||
|
<div data-el="edit-components"></div>
|
||||||
|
<div style="display: flex; gap: 8px; margin-top: 16px;">
|
||||||
|
<button data-el="edit-update">Update</button>
|
||||||
|
<button data-el="edit-remove">Remove</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
`
|
`
|
||||||
}
|
}// }}}
|
||||||
constructor() {
|
constructor() {// {{{
|
||||||
super(true)
|
super(true)
|
||||||
const script = this.querySelector('script')
|
const script = this.querySelector('script')
|
||||||
this.data = JSON.parse(script.textContent)
|
this.data = JSON.parse(script.textContent)
|
||||||
|
globalThis.foo = this.data
|
||||||
|
this.widgets = new Map() // keyed by datapoint ID, value is an array of widgets.
|
||||||
|
this.addColumns = 0
|
||||||
|
this.addRows = 0
|
||||||
|
this.editedWidget = null
|
||||||
|
|
||||||
|
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.saveDashboard())
|
||||||
|
this.elEditUpdate.addEventListener('click', () => this.editUpdate())
|
||||||
|
|
||||||
this.render()
|
this.render()
|
||||||
}
|
this.fetchData() // Retrieve one as fast as possible since setInterval doesn't run before an interval.
|
||||||
|
setInterval(() => this.fetchData(), 1000)
|
||||||
|
}// }}}
|
||||||
|
|
||||||
render() {
|
render() {// {{{
|
||||||
const widgets = []
|
const widgets = []
|
||||||
|
this.widgets = new Map()
|
||||||
|
|
||||||
|
let maxX = 0
|
||||||
|
let maxY = 0
|
||||||
|
let occupiedCells = new Map()
|
||||||
|
|
||||||
for (const wd of this.data.Widgets) {
|
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)
|
const widget = SmonWidget.create(wd.Type, wd)
|
||||||
|
widget.addEventListener('click', () => this.selectCell(widget))
|
||||||
|
widgetRefs.push(widget)
|
||||||
widgets.push(widget)
|
widgets.push(widget)
|
||||||
}
|
|
||||||
this.elGrid.replaceChildren(...widgets)
|
maxX = Math.max(maxX, wd.X)
|
||||||
|
maxY = Math.max(maxY, wd.Y)
|
||||||
|
|
||||||
|
occupiedCells.set(`${wd.X}x${wd.Y}`, true)
|
||||||
|
for (let x = 0; x <= wd.SpanX; x++) {
|
||||||
|
occupiedCells.set(`${wd.X + x}x${wd.Y}`, true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class SmonWidget extends CustomHTMLElement {
|
maxX += this.addColumns
|
||||||
static create(t, data) {
|
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 (occupiedCells.has(`${x}x${y}`))
|
||||||
|
continue
|
||||||
|
|
||||||
|
const empty = new SmonWidgetEmpty(x, y)
|
||||||
|
empty.addEventListener('click', () => this.selectCell(empty))
|
||||||
|
widgets.push(empty)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.elGrid.replaceChildren(...widgets)
|
||||||
|
}// }}}
|
||||||
|
error(msg) {// {{{
|
||||||
|
this.elError.innerText = msg
|
||||||
|
if (msg === '')
|
||||||
|
this.elError.classList.remove('show')
|
||||||
|
else
|
||||||
|
this.elError.classList.add('show')
|
||||||
|
}// }}}
|
||||||
|
saveDashboard() {// {{{
|
||||||
|
}// }}}
|
||||||
|
async editUpdate() {// {{{
|
||||||
|
await this.editedWidget.editUpdate()
|
||||||
|
this.elEdit.close()
|
||||||
|
this.editedWidget = null
|
||||||
|
this.render()
|
||||||
|
}// }}}
|
||||||
|
selectCell(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)
|
||||||
|
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)
|
||||||
|
w.render()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}// }}}
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
class SmonWidget extends CustomHTMLElement {// {{{
|
||||||
|
static create(t, data) {// {{{
|
||||||
switch (t) {
|
switch (t) {
|
||||||
case 0: return new SmonWidgetLabel(data)
|
case 0: return new SmonWidgetLabel(data)
|
||||||
case 1: return new SmonWidgetOnOff(data)
|
case 1: return new SmonWidgetOnOff(data)
|
||||||
}
|
}
|
||||||
}
|
}// }}}
|
||||||
|
constructor(data) {// {{{
|
||||||
constructor(data) {
|
|
||||||
super(true)
|
super(true)
|
||||||
this.data = data
|
this.data = data
|
||||||
}
|
this.value = null
|
||||||
|
this.editDialog = null
|
||||||
|
}// }}}
|
||||||
|
|
||||||
render() {
|
render() {// {{{
|
||||||
this.style.gridColumn = this.data.X
|
this.style.gridColumn = this.data.X
|
||||||
this.style.gridRow = this.data.Y
|
this.style.gridRow = this.data.Y
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SmonWidgetLabel extends SmonWidget {
|
if (this.data.SpanX > 0)
|
||||||
static {
|
this.style.gridColumn = `${this.data.X} / ${this.data.X + this.data.SpanX + 1}`
|
||||||
|
|
||||||
|
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`
|
||||||
|
}
|
||||||
|
}// }}}
|
||||||
|
setValue(v) {// {{{
|
||||||
|
this.value = v
|
||||||
|
}// }}}
|
||||||
|
edit() {// {{{
|
||||||
|
if (this.editDialog !== null)
|
||||||
|
return
|
||||||
|
|
||||||
|
const components = this.editWidget()
|
||||||
|
return components
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
// 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(x, y) {// {{{
|
||||||
|
super({
|
||||||
|
Attributes: {},
|
||||||
|
X: x,
|
||||||
|
Y: y,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.classList.add('empty')
|
||||||
|
this.render()
|
||||||
|
}// }}}
|
||||||
|
render() {// {{{
|
||||||
|
super.render()
|
||||||
|
}// }}}
|
||||||
|
editWidget() {// {{{
|
||||||
|
const div = document.createElement('div')
|
||||||
|
div.innerHTML = `
|
||||||
|
<div>Add widget:</div>
|
||||||
|
<select style="width: 100%; padding: 4px 4px">
|
||||||
|
<option value="0">Label</option>
|
||||||
|
<option value="1">On / off</option>
|
||||||
|
</select>
|
||||||
|
`
|
||||||
|
return [div]
|
||||||
|
}// }}}
|
||||||
|
}// }}}
|
||||||
|
class SmonWidgetLabel extends SmonWidget {// {{{
|
||||||
|
static {// {{{
|
||||||
this.tmpl = document.createElement('template')
|
this.tmpl = document.createElement('template')
|
||||||
this.tmpl.innerHTML = `
|
this.tmpl.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
.el-label {
|
.el-label {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.5em;
|
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<div data-el="label"></div>
|
<div data-el="label"></div>
|
||||||
`
|
`
|
||||||
}
|
}// }}}
|
||||||
|
constructor(data) {// {{{
|
||||||
constructor(data) {
|
|
||||||
super(data)
|
super(data)
|
||||||
this.render()
|
this.render()
|
||||||
}
|
}// }}}
|
||||||
|
render() {// {{{
|
||||||
render() {
|
|
||||||
super.render()
|
super.render()
|
||||||
this.elLabel.innerText = this.data.Attributes?.Label || '[set Label]'
|
this.elLabel.innerText = this.data.Attributes?.Label || '[set Label]'
|
||||||
}
|
this.applyLabelAttributes(this.elLabel)
|
||||||
}
|
}// }}}
|
||||||
|
editWidget() {// {{{
|
||||||
class SmonWidgetOnOff extends SmonWidget {
|
this.editDiv = document.createElement('div')
|
||||||
static {
|
this.editDiv.innerHTML = `
|
||||||
|
<div>Label:</div>
|
||||||
|
<input type="text">
|
||||||
|
`
|
||||||
|
this.editDiv.querySelector('input').value = this.data.Attributes.Label
|
||||||
|
return [this.editDiv]
|
||||||
|
}// }}}
|
||||||
|
async editUpdate() {// {{{
|
||||||
|
console.log('hum')
|
||||||
|
this.data.Attributes.Label = this.editDiv.querySelector('input').value
|
||||||
|
}// }}}
|
||||||
|
}// }}}
|
||||||
|
class SmonWidgetOnOff extends SmonWidget {// {{{
|
||||||
|
static {// {{{
|
||||||
this.tmpl = document.createElement('template')
|
this.tmpl = document.createElement('template')
|
||||||
this.tmpl.innerHTML = `
|
this.tmpl.innerHTML = `
|
||||||
<style>
|
<style>
|
||||||
|
|
@ -94,29 +376,33 @@ class SmonWidgetOnOff extends SmonWidget {
|
||||||
grid-template-columns: min-content 1fr;
|
grid-template-columns: min-content 1fr;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
grid-gap: 8px;
|
grid-gap: 8px;
|
||||||
|
|
||||||
font-size: 1.25em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
<img data-el="status">
|
<img data-el="status">
|
||||||
<div data-el="label"></div>
|
<div data-el="label"></div>
|
||||||
`
|
`
|
||||||
}
|
}// }}}
|
||||||
|
constructor(data) {// {{{
|
||||||
constructor(data) {
|
|
||||||
super(data)
|
super(data)
|
||||||
this.render()
|
this.render()
|
||||||
}
|
}// }}}
|
||||||
|
render() {// {{{
|
||||||
render() {
|
|
||||||
super.render()
|
super.render()
|
||||||
|
|
||||||
let img
|
let img
|
||||||
switch (this.data.Attributes.Status) {
|
switch (this.value) {
|
||||||
|
case 1:
|
||||||
case 'ON':
|
case 'ON':
|
||||||
img = 'widget_light_green.svg'
|
img = 'widget_light_green.svg'
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case 0:
|
||||||
case 'OFF':
|
case 'OFF':
|
||||||
img = 'widget_light_red.svg'
|
img = 'widget_light_red.svg'
|
||||||
break
|
break
|
||||||
|
|
@ -127,11 +413,25 @@ class SmonWidgetOnOff extends SmonWidget {
|
||||||
|
|
||||||
this.elStatus.setAttribute('src', `/images/${_VERSION}/${img}`)
|
this.elStatus.setAttribute('src', `/images/${_VERSION}/${img}`)
|
||||||
this.elLabel.innerText = this.data.Attributes.Label
|
this.elLabel.innerText = this.data.Attributes.Label
|
||||||
}
|
this.applyLabelAttributes(this.elLabel)
|
||||||
}
|
}// }}}
|
||||||
|
editWidget() {// {{{
|
||||||
|
this.editDiv = document.createElement('div')
|
||||||
|
this.editDiv.innerHTML = `
|
||||||
|
<div>Label:</div>
|
||||||
|
<input type="text">
|
||||||
|
`
|
||||||
|
this.editDiv.querySelector('input').value = this.data.Attributes.Label
|
||||||
|
return [this.editDiv]
|
||||||
|
}// }}}
|
||||||
|
async editUpdate() {// {{{
|
||||||
|
this.data.Attributes.Label = this.editDiv.querySelector('input').value
|
||||||
|
}// }}}
|
||||||
|
}// }}}
|
||||||
|
|
||||||
|
|
||||||
customElements.define("smon-widget", SmonWidget)
|
customElements.define("smon-widget", SmonWidget)
|
||||||
|
customElements.define("smon-widget-empty", SmonWidgetEmpty)
|
||||||
customElements.define("smon-widget-label", SmonWidgetLabel)
|
customElements.define("smon-widget-label", SmonWidgetLabel)
|
||||||
customElements.define("smon-widget-onoff", SmonWidgetOnOff)
|
customElements.define("smon-widget-onoff", SmonWidgetOnOff)
|
||||||
customElements.define("smon-dashboard", SmonDashboard)
|
customElements.define("smon-dashboard", SmonDashboard)
|
||||||
|
|
|
||||||
|
|
@ -5,14 +5,12 @@
|
||||||
import { } from '/js/{{ .VERSION }}/dashboard.mjs'
|
import { } from '/js/{{ .VERSION }}/dashboard.mjs'
|
||||||
</script>
|
</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>
|
<div>
|
||||||
<img src="/images/{{ .VERSION }}/{{ .CONFIG.THEME }}/logo.svg" style="width: 64px; margin-right: 32px;">
|
<h1 style="margin-bottom: 0px">Smon</h1>
|
||||||
|
<h2 style="margin-top: 0px">{{ .VERSION }}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="display: grid; grid-template-columns: min-content 1fr; align-items: end; grid-gap: 8px;">
|
|
||||||
<h1>SMon</h1>
|
|
||||||
<h2>{{ .VERSION }}</h2>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<smon-dashboard style="margin-top: 32px">
|
<smon-dashboard style="margin-top: 32px">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue