diff --git a/dashboard.go b/dashboard.go
new file mode 100644
index 0000000..dfb29a9
--- /dev/null
+++ b/dashboard.go
@@ -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
+}
diff --git a/datapoint.go b/datapoint.go
index b47ccc9..f540db8 100644
--- a/datapoint.go
+++ b/datapoint.go
@@ -28,13 +28,35 @@ type Datapoint struct {
Datatype DatapointType
Comment string
LastValue time.Time `db:"last_value"`
+ DataPointID int `db:"datapoint_id"`
DatapointValueJSON []byte `db:"datapoint_value_json"`
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
NodataProblemSeconds int `db:"nodata_problem_seconds"`
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 {
ID int
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)
- dp, err = pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Datapoint])
+ dp, err = pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[Datapoint])
if err != nil {
err = werr.Wrap(err)
return
@@ -292,7 +314,7 @@ func DatapointRetrieve(id int, name string) (dp Datapoint, err error) { // {{{
`,
dp.ID,
)
- dp, err = pgx.CollectExactlyOneRow(rows, pgx.RowToStructByName[Datapoint])
+ dp.LastDatapointValue, err = pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[DatapointValue])
if err == sql.ErrNoRows {
err = nil
return
@@ -370,3 +392,53 @@ func DatapointValues(id int, from, to time.Time) (values []DatapointValue, err e
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
+} // }}}
diff --git a/go.mod b/go.mod
index 6880382..7156421 100644
--- a/go.mod
+++ b/go.mod
@@ -1,6 +1,6 @@
module smon
-go 1.25.0
+go 1.26.0
require (
git.ahall.se/go/html_template v0.1.1
diff --git a/main.go b/main.go
index d6f81cd..4094aec 100644
--- a/main.go
+++ b/main.go
@@ -29,7 +29,7 @@ import (
"time"
)
-const VERSION = "v41"
+const VERSION = "v42"
var (
logger *slog.Logger
@@ -160,6 +160,9 @@ func main() { // {{{
http.HandleFunc("/configuration/notification/delete/{prio}", actionConfigurationNotificationDelete)
http.HandleFunc("/entry/{datapoint}", actionEntryDatapoint)
+ http.HandleFunc("GET /dashboard/values", actionDashboardValues)
+ http.HandleFunc("POST /dashboard/update", actionDashboardUpdate)
+
go nodataLoop()
smonConfig, err = SmonConfigInit()
@@ -405,8 +408,52 @@ func pageIndex(w http.ResponseWriter, r *http.Request) { // {{{
PAGE: "index",
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)
} // }}}
+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) { // {{{
name := r.PathValue("name")
@@ -418,7 +465,6 @@ func actionAreaNew(w http.ResponseWriter, r *http.Request) { // {{{
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
- return
} // }}}
func actionAreaRename(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
@@ -437,7 +483,6 @@ func actionAreaRename(w http.ResponseWriter, r *http.Request) { // {{{
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
- return
} // }}}
func actionAreaDelete(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
@@ -455,7 +500,6 @@ func actionAreaDelete(w http.ResponseWriter, r *http.Request) { // {{{
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
- return
} // }}}
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.WriteHeader(302)
- return
} // }}}
func actionSectionRename(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
@@ -494,7 +537,6 @@ func actionSectionRename(w http.ResponseWriter, r *http.Request) { // {{{
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
- return
} // }}}
func actionSectionDelete(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
@@ -512,7 +554,6 @@ func actionSectionDelete(w http.ResponseWriter, r *http.Request) { // {{{
w.Header().Add("Location", "/configuration")
w.WriteHeader(302)
- return
} // }}}
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"),
}
page.Render(w, r)
- return
} // }}}
func actionProblemAcknowledge(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
@@ -595,8 +635,6 @@ func actionProblemAcknowledge(w http.ResponseWriter, r *http.Request) { // {{{
w.Header().Add("Location", "/problems")
w.WriteHeader(302)
-
- return
} // }}}
func actionProblemUnacknowledge(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
@@ -614,8 +652,6 @@ func actionProblemUnacknowledge(w http.ResponseWriter, r *http.Request) { // {{{
w.Header().Add("Location", "/problems")
w.WriteHeader(302)
-
- return
} // }}}
func pageDatapoints(w http.ResponseWriter, r *http.Request) { // {{{
@@ -642,7 +678,6 @@ func pageDatapoints(w http.ResponseWriter, r *http.Request) { // {{{
"Datapoints": datapoints,
}
page.Render(w, r)
- return
} // }}}
func pageDatapointEdit(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
@@ -699,7 +734,6 @@ func pageDatapointEdit(w http.ResponseWriter, r *http.Request) { // {{{
"Triggers": triggers,
}
page.Render(w, r)
- return
} // }}}
func actionDatapointUpdate(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
@@ -832,7 +866,6 @@ func pageDatapointValues(w http.ResponseWriter, r *http.Request) { // {{{
"TimeTo": timeTo.Format("2006-01-02T15:04:05"),
}
page.Render(w, r)
- return
} // }}}
func actionDatapointJson(w http.ResponseWriter, r *http.Request) { // {{{
idStr := r.PathValue("id")
diff --git a/problem.go b/problem.go
index 1d4390d..d5513dd 100644
--- a/problem.go
+++ b/problem.go
@@ -3,11 +3,13 @@ package main
import (
// External
werr "git.ahall.se/go/wrappederror"
+ "github.com/jackc/pgx/v5"
// Standard
"context"
"database/sql"
"encoding/json"
+ "errors"
"fmt"
"sort"
"strings"
@@ -152,7 +154,7 @@ func ProblemClose(trigger Trigger) (problemID int, err error) { // {{{
)
err = row.Scan(&problemID)
- if err == sql.ErrNoRows {
+ if errors.Is(err, pgx.ErrNoRows) {
err = nil
return
}
diff --git a/sql/00028.sql b/sql/00028.sql
new file mode 100644
index 0000000..3350ded
--- /dev/null
+++ b/sql/00028.sql
@@ -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")
+);
diff --git a/static/css/gruvbox/gruvbox.css b/static/css/gruvbox/gruvbox.css
index 6acdbc0..e041022 100644
--- a/static/css/gruvbox/gruvbox.css
+++ b/static/css/gruvbox/gruvbox.css
@@ -1,5 +1,5 @@
body {
- background-image: url(/images/v0/gruvbox/background.svg);
+ /* background-image: url(/images/v0/gruvbox/background.svg); */
}
#menu {
box-shadow: 2px 0px 5px 3px rgba(0, 0, 0, 0.25);
diff --git a/static/images/grid_management.svg b/static/images/grid_management.svg
new file mode 100644
index 0000000..be8a424
--- /dev/null
+++ b/static/images/grid_management.svg
@@ -0,0 +1,1123 @@
+
+
+
+
diff --git a/static/images/widget_light_green.svg b/static/images/widget_light_green.svg
new file mode 100644
index 0000000..a0bc5a5
--- /dev/null
+++ b/static/images/widget_light_green.svg
@@ -0,0 +1,49 @@
+
+
+
+
diff --git a/static/images/widget_light_off.svg b/static/images/widget_light_off.svg
new file mode 100644
index 0000000..bcaed9f
--- /dev/null
+++ b/static/images/widget_light_off.svg
@@ -0,0 +1,49 @@
+
+
+
+
diff --git a/static/images/widget_light_red.svg b/static/images/widget_light_red.svg
new file mode 100644
index 0000000..063f076
--- /dev/null
+++ b/static/images/widget_light_red.svg
@@ -0,0 +1,49 @@
+
+
+
+
diff --git a/static/js/dashboard.mjs b/static/js/dashboard.mjs
new file mode 100644
index 0000000..cd00746
--- /dev/null
+++ b/static/js/dashboard.mjs
@@ -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 = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `
+ }// }}}
+ 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 = `
+
+ `
+ 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 = `
+
+ `
+ }// }}}
+ constructor(data) {// {{{
+ super(data)
+ this.render()
+ }// }}}
+ render() {// {{{
+ super.render()
+ }// }}}
+ editWidget() {// {{{
+ this.editDiv = document.createElement('div')
+ this.editDiv.innerHTML = `
+ Add widget
+
+ `
+ 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 = `
+
+ `
+ }// }}}
+ 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 = `
+ Spacing
+
+ `
+
+ 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 = `
+
+
+ `
+ }// }}}
+ 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 = `
+ Label
+
+ `
+ 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 = `
+
+
+
+ `
+ }// }}}
+ 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 = `
+ Label
+
+ `
+ 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)
diff --git a/static/js/lib/custom_html_element.mjs b/static/js/lib/custom_html_element.mjs
new file mode 100644
index 0000000..d1fb7ae
--- /dev/null
+++ b/static/js/lib/custom_html_element.mjs
@@ -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('-', ''))
+ }// }}}
+}
diff --git a/views/layouts/main.gotmpl b/views/layouts/main.gotmpl
index b82b49f..263d61f 100644
--- a/views/layouts/main.gotmpl
+++ b/views/layouts/main.gotmpl
@@ -6,6 +6,9 @@
{{ template "fonts" }}
+
diff --git a/views/pages/index.gotmpl b/views/pages/index.gotmpl
index f5d7056..8d1ead5 100644
--- a/views/pages/index.gotmpl
+++ b/views/pages/index.gotmpl
@@ -1,13 +1,19 @@
{{ define "page" }}
-
-

+
+
+
+

+
+
Smon
+ {{ .VERSION }}
+
-
-
SMon
- {{ .VERSION }}
-
-
+
+
+
{{ end }}