From 1af3f59b1aee9afd0916b6006f8dfc502028196c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Thu, 20 Aug 2026 07:05:54 +0200 Subject: [PATCH 1/4] Initial dashboard --- dashboard.go | 20 ++++ main.go | 11 +++ static/css/gruvbox/gruvbox.css | 2 +- static/images/widget_light_green.svg | 49 +++++++++ static/images/widget_light_off.svg | 49 +++++++++ static/images/widget_light_red.svg | 49 +++++++++ static/js/dashboard.mjs | 137 ++++++++++++++++++++++++++ static/js/lib/custom_html_element.mjs | 51 ++++++++++ views/layouts/main.gotmpl | 3 + views/pages/index.gotmpl | 12 ++- 10 files changed, 380 insertions(+), 3 deletions(-) create mode 100644 dashboard.go create mode 100644 static/images/widget_light_green.svg create mode 100644 static/images/widget_light_off.svg create mode 100644 static/images/widget_light_red.svg create mode 100644 static/js/dashboard.mjs create mode 100644 static/js/lib/custom_html_element.mjs diff --git a/dashboard.go b/dashboard.go new file mode 100644 index 0000000..fed531a --- /dev/null +++ b/dashboard.go @@ -0,0 +1,20 @@ +package main + +type WidgetType int + +const ( + WidgetLabel WidgetType = iota + WidgetOnOff +) + +type Widget struct { + Type WidgetType + X int + Y int + DatapointID int + Attributes map[string]string +} + +type Dashboard struct { + Widgets []Widget +} diff --git a/main.go b/main.go index d6f81cd..80a1638 100644 --- a/main.go +++ b/main.go @@ -405,6 +405,17 @@ func pageIndex(w http.ResponseWriter, r *http.Request) { // {{{ PAGE: "index", 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"}}, + {Type: 1, X: 1, Y: 3, DatapointID: 13, Attributes: map[string]string{"Label":"Databasus"}}, + }, + } + page.Data = data page.Render(w, r) } // }}} 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/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..11a7e9d --- /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..29d151a --- /dev/null +++ b/static/js/dashboard.mjs @@ -0,0 +1,137 @@ +import { CustomHTMLElement } from './lib/custom_html_element.mjs' + +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.render() + } + + render() { + const widgets = [] + for (const wd of this.data.Widgets) { + const widget = SmonWidget.create(wd.Type, wd) + widgets.push(widget) + } + this.elGrid.replaceChildren(...widgets) + } +} + +class SmonWidget extends CustomHTMLElement { + static create(t, data) { + switch (t) { + case 0: return new SmonWidgetLabel(data) + case 1: return new SmonWidgetOnOff(data) + } + } + + constructor(data) { + super(true) + this.data = data + } + + render() { + this.style.gridColumn = this.data.X + this.style.gridRow = this.data.Y + } +} + +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]' + } +} + +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.data.Attributes.Status) { + case 'ON': + img = 'widget_light_green.svg' + break + + case 'OFF': + img = 'widget_light_red.svg' + break + + default: + img = 'widget_light_off.svg' + } + + this.elStatus.setAttribute('src', `/images/${_VERSION}/${img}`) + this.elLabel.innerText = this.data.Attributes.Label + } +} + + +customElements.define("smon-widget", SmonWidget) +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..bae426f 100644 --- a/views/pages/index.gotmpl +++ b/views/pages/index.gotmpl @@ -1,13 +1,21 @@ {{ define "page" }} -
+ + +
-
+

SMon

{{ .VERSION }}

+ + + + {{ end }} From c3fe5951d43ebf0be10e0762c5866ecceeb74942 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Thu, 20 Aug 2026 11:13:03 +0200 Subject: [PATCH 2/4] Added start of editable dashboard --- dashboard.go | 34 ++- datapoint.go | 71 +++++ go.mod | 2 +- main.go | 46 ++-- sql/00028.sql | 7 + static/images/widget_light_red.svg | 16 +- static/js/dashboard.mjs | 404 +++++++++++++++++++++++++---- views/pages/index.gotmpl | 14 +- 8 files changed, 502 insertions(+), 92 deletions(-) create mode 100644 sql/00028.sql diff --git a/dashboard.go b/dashboard.go index fed531a..fcb24f3 100644 --- a/dashboard.go +++ b/dashboard.go @@ -1,20 +1,50 @@ package main +import ( + // External + werr "git.ahall.se/go/wrappederror" + "github.com/jackc/pgx/v5" + + // Standard + "context" +) + type WidgetType int const ( - WidgetLabel WidgetType = iota - WidgetOnOff + WIDGET_LABEL WidgetType = iota + 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 +} diff --git a/datapoint.go b/datapoint.go index b47ccc9..1471db9 100644 --- a/datapoint.go +++ b/datapoint.go @@ -30,11 +30,32 @@ type Datapoint struct { LastValue time.Time `db:"last_value"` 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"` @@ -370,3 +391,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 80a1638..e57d907 100644 --- a/main.go +++ b/main.go @@ -160,6 +160,8 @@ func main() { // {{{ http.HandleFunc("/configuration/notification/delete/{prio}", actionConfigurationNotificationDelete) http.HandleFunc("/entry/{datapoint}", actionEntryDatapoint) + http.HandleFunc("/dashboard/values", actionDashboardValues) + go nodataLoop() smonConfig, err = SmonConfigInit() @@ -406,18 +408,34 @@ func pageIndex(w http.ResponseWriter, r *http.Request) { // {{{ 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"}}, - {Type: 1, X: 1, Y: 3, DatapointID: 13, Attributes: map[string]string{"Label":"Databasus"}}, - }, + 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 actionAreaNew(w http.ResponseWriter, r *http.Request) { // {{{ name := r.PathValue("name") @@ -429,7 +447,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") @@ -448,7 +465,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") @@ -466,7 +482,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) { // {{{ @@ -486,7 +501,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") @@ -505,7 +519,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") @@ -523,7 +536,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) { // {{{ @@ -588,7 +600,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") @@ -606,8 +617,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") @@ -625,8 +634,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) { // {{{ @@ -653,7 +660,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") @@ -710,7 +716,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") @@ -843,7 +848,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/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/images/widget_light_red.svg b/static/images/widget_light_red.svg index 11a7e9d..063f076 100644 --- a/static/images/widget_light_red.svg +++ b/static/images/widget_light_red.svg @@ -7,8 +7,8 @@ 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" + 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" @@ -25,11 +25,11 @@ 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: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" /> @@ -40,7 +40,7 @@ inkscape:groupmode="layer" id="layer1"> :host { + --edit-border: 1px solid #888; + 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 { display: grid; grid-auto-flow: column; grid-auto-columns: min-content; 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; + } + } + +
+ + + + +
+ +
+ + +
+
+ + +
+
` - } - constructor() { + }// }}} + constructor() {// {{{ super(true) const script = this.querySelector('script') 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.fetchData() // Retrieve one as fast as possible since setInterval doesn't run before an interval. + setInterval(() => this.fetchData(), 1000) + }// }}} - render() { + render() {// {{{ const widgets = [] - for (const wd of this.data.Widgets) { - const widget = SmonWidget.create(wd.Type, wd) - widgets.push(widget) - } - this.elGrid.replaceChildren(...widgets) - } -} + this.widgets = new Map() -class SmonWidget extends CustomHTMLElement { - static create(t, data) { + let maxX = 0 + let maxY = 0 + let occupiedCells = new Map() + + 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.selectCell(widget)) + widgetRefs.push(widget) + widgets.push(widget) + + 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) + } + } + + 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 (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) { case 0: return new SmonWidgetLabel(data) case 1: return new SmonWidgetOnOff(data) } - } - - constructor(data) { + }// }}} + constructor(data) {// {{{ super(true) this.data = data - } + this.value = null + this.editDialog = null + }// }}} - render() { + render() {// {{{ this.style.gridColumn = this.data.X this.style.gridRow = this.data.Y - } -} -class SmonWidgetLabel extends SmonWidget { - static { + if (this.data.SpanX > 0) + 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 = ` + + ` + }// }}} + 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 = ` +
Add widget:
+ + ` + return [div] + }// }}} +}// }}} +class SmonWidgetLabel extends SmonWidget {// {{{ + static {// {{{ this.tmpl = document.createElement('template') this.tmpl.innerHTML = `
` - } - - constructor(data) { + }// }}} + constructor(data) {// {{{ super(data) this.render() - } - - render() { + }// }}} + render() {// {{{ super.render() this.elLabel.innerText = this.data.Attributes?.Label || '[set Label]' - } -} - -class SmonWidgetOnOff extends SmonWidget { - static { + this.applyLabelAttributes(this.elLabel) + }// }}} + editWidget() {// {{{ + this.editDiv = document.createElement('div') + this.editDiv.innerHTML = ` +
Label:
+ + ` + 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.innerHTML = `
` - } - - constructor(data) { + }// }}} + constructor(data) {// {{{ super(data) this.render() - } - - render() { + }// }}} + render() {// {{{ super.render() let img - switch (this.data.Attributes.Status) { + switch (this.value) { + case 1: case 'ON': img = 'widget_light_green.svg' break + case 0: case 'OFF': img = 'widget_light_red.svg' break @@ -127,11 +413,25 @@ class SmonWidgetOnOff extends SmonWidget { this.elStatus.setAttribute('src', `/images/${_VERSION}/${img}`) this.elLabel.innerText = this.data.Attributes.Label - } -} + this.applyLabelAttributes(this.elLabel) + }// }}} + editWidget() {// {{{ + this.editDiv = document.createElement('div') + this.editDiv.innerHTML = ` +
Label:
+ + ` + 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-empty", SmonWidgetEmpty) customElements.define("smon-widget-label", SmonWidgetLabel) customElements.define("smon-widget-onoff", SmonWidgetOnOff) customElements.define("smon-dashboard", SmonDashboard) diff --git a/views/pages/index.gotmpl b/views/pages/index.gotmpl index bae426f..8d1ead5 100644 --- a/views/pages/index.gotmpl +++ b/views/pages/index.gotmpl @@ -5,14 +5,12 @@ import { } from '/js/{{ .VERSION }}/dashboard.mjs' -
- -
- -
-

SMon

-

{{ .VERSION }}

- +
+ +
+

Smon

+

{{ .VERSION }}

+
From dc0ad136e61b7cae545889f8354763b3df1ee005 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Fri, 21 Aug 2026 11:03:14 +0200 Subject: [PATCH 3/4] Dashboard editing --- dashboard.go | 21 +- datapoint.go | 5 +- main.go | 22 +- problem.go | 4 +- static/images/grid_management.svg | 1123 +++++++++++++++++++++++++++++ static/js/dashboard.mjs | 486 +++++++++++-- 6 files changed, 1596 insertions(+), 65 deletions(-) create mode 100644 static/images/grid_management.svg diff --git a/dashboard.go b/dashboard.go index fcb24f3..dfb29a9 100644 --- a/dashboard.go +++ b/dashboard.go @@ -7,12 +7,14 @@ import ( // Standard "context" + "encoding/json" ) type WidgetType int const ( - WIDGET_LABEL WidgetType = iota + WIDGET_SPACE WidgetType = iota + WIDGET_LABEL WIDGET_ONOFF ) @@ -48,3 +50,20 @@ func DashboardGet(name string) (dashboard Dashboard, err error) { 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 1471db9..f540db8 100644 --- a/datapoint.go +++ b/datapoint.go @@ -28,6 +28,7 @@ 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"` @@ -283,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 @@ -313,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 diff --git a/main.go b/main.go index e57d907..0cb8574 100644 --- a/main.go +++ b/main.go @@ -160,7 +160,8 @@ func main() { // {{{ http.HandleFunc("/configuration/notification/delete/{prio}", actionConfigurationNotificationDelete) http.HandleFunc("/entry/{datapoint}", actionEntryDatapoint) - http.HandleFunc("/dashboard/values", actionDashboardValues) + http.HandleFunc("GET /dashboard/values", actionDashboardValues) + http.HandleFunc("POST /dashboard/update", actionDashboardUpdate) go nodataLoop() @@ -408,7 +409,6 @@ func pageIndex(w http.ResponseWriter, r *http.Request) { // {{{ CONFIG: smonConfig.Settings, } - dashboard, err := DashboardGet("start") if err != nil { logger.Error("dashboard", "error", err) @@ -436,6 +436,24 @@ func actionDashboardValues(w http.ResponseWriter, r *http.Request) { // {{{ }) 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") 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/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/js/dashboard.mjs b/static/js/dashboard.mjs index a8c8f5f..cd00746 100644 --- a/static/js/dashboard.mjs +++ b/static/js/dashboard.mjs @@ -1,5 +1,9 @@ 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') @@ -13,7 +17,6 @@ export class SmonDashboard extends CustomHTMLElement {// {{{ :host(.edit) { .el-grid { - grid-gap: unset; border-top: var(--edit-border); border-left: var(--edit-border); @@ -24,12 +27,17 @@ export class SmonDashboard extends CustomHTMLElement {// {{{ } & > *:hover { - background-color: rgba(255, 255, 255, 0.1); + background-color: rgba(255, 255, 255, 0.1) !important; cursor: pointer; } - & > .empty { + smon-widget-space { + background: rgba(128, 255, 128, 0.10); + } + + & > smon-widget-empty { display: block; + background: unset; } } @@ -59,12 +67,15 @@ export class SmonDashboard extends CustomHTMLElement {// {{{ display: grid; grid-auto-flow: column; grid-auto-columns: min-content; - grid-gap: 8px 16px; width: min-content; - - .empty { + + smon-widget-empty { display: none; } + + & > * { + padding: 4px 0px; + } } .only-on-edit { @@ -79,6 +90,10 @@ export class SmonDashboard extends CustomHTMLElement {// {{{ } dialog { + svg { + cursor: pointer; + } + input[type="text"] { padding: 4px; margin-top: 4px; @@ -107,11 +122,34 @@ export class SmonDashboard extends CustomHTMLElement {// {{{
+
Cell operations
+
+
+ + + + +
+ +
+ + + + +
+ +
+ + + +
+ +
-
+
` }// }}} @@ -119,18 +157,32 @@ export class SmonDashboard extends CustomHTMLElement {// {{{ super(true) const script = this.querySelector('script') 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.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.saveDashboard()) + 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) @@ -139,10 +191,10 @@ export class SmonDashboard extends CustomHTMLElement {// {{{ render() {// {{{ const widgets = [] this.widgets = new Map() + this.occupiedCells = new Map() let maxX = 0 let maxY = 0 - let occupiedCells = new Map() for (const wd of this.data.Widgets) { if (!this.widgets.has(wd.DatapointID)) @@ -150,17 +202,18 @@ export class SmonDashboard extends CustomHTMLElement {// {{{ // 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.selectCell(widget)) + widget.addEventListener('click', () => this.editCell(widget)) widgetRefs.push(widget) widgets.push(widget) 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) - } + 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 @@ -170,14 +223,16 @@ export class SmonDashboard extends CustomHTMLElement {// {{{ // 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}`)) + if (this.occupiedCells.has(`${x}x${y}`)) continue - const empty = new SmonWidgetEmpty(x, y) - empty.addEventListener('click', () => this.selectCell(empty)) + 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) {// {{{ @@ -187,15 +242,30 @@ export class SmonDashboard extends CustomHTMLElement {// {{{ else this.elError.classList.add('show') }// }}} - saveDashboard() {// {{{ - }// }}} async editUpdate() {// {{{ - await this.editedWidget.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() }// }}} - selectCell(el) {// {{{ + editCell(el) {// {{{ if (!this.classList.contains('edit')) return @@ -215,7 +285,7 @@ export class SmonDashboard extends CustomHTMLElement {// {{{ for (const dp of json.Values) { const widgets = this.widgets.get(dp.ID) || [] for (const w of widgets) { - w.setValue(dp.Value) + w.setValue(dp.Value, dp.Valid) w.render() } } @@ -230,26 +300,170 @@ export class SmonDashboard extends CustomHTMLElement {// {{{ for (const id of allDpIDs) { const widgets = this.widgets.get(id) || [] for (const w of widgets) { - w.setValue(null) + 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 0: return new SmonWidgetLabel(data) - case 1: return new SmonWidgetOnOff(data) + 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() {// {{{ @@ -259,23 +473,131 @@ class SmonWidget extends CustomHTMLElement {// {{{ 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) {// {{{ - this.value = v + setValue(v, valid) {// {{{ + this.value = valid ? v : null }// }}} edit() {// {{{ if (this.editDialog !== null) return - const components = this.editWidget() + const components = this.commonEditWidgets() + components.push(...this.editWidget()) return components }// }}} + commonEditWidgets() {// {{{ + this.editDiv = document.createElement('div') + this.editDiv.innerHTML = ` +
+
+
Borders
+
+ + + + +
+
+ +
+
Spanning
+
+
X
+ + +
Y
+ +
+
+ +
+
Font
+
+
Size
+ + +
Color
+
+ + +
+
+
+
+ ` + 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. @@ -292,6 +614,7 @@ class SmonWidget extends CustomHTMLElement {// {{{ else el.style.borderBottom = `1px solid currentColor` }// }}} + }// }}} class SmonWidgetEmpty extends SmonWidget {// {{{ static {// {{{ @@ -305,29 +628,73 @@ class SmonWidgetEmpty extends SmonWidget {// {{{ ` }// }}} - constructor(x, y) {// {{{ - super({ - Attributes: {}, - X: x, - Y: y, - }) - - this.classList.add('empty') + constructor(data) {// {{{ + super(data) this.render() }// }}} render() {// {{{ super.render() }// }}} editWidget() {// {{{ - const div = document.createElement('div') - div.innerHTML = ` -
Add widget:
+ this.editDiv = document.createElement('div') + this.editDiv.innerHTML = ` +
Add widget
` - return [div] + 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
+
+
Width
+ + +
Height
+ +
+ ` + + 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 {// {{{ @@ -337,7 +704,7 @@ class SmonWidgetLabel extends SmonWidget {// {{{
@@ -349,21 +716,21 @@ class SmonWidgetLabel extends SmonWidget {// {{{ }// }}} 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() {// {{{ this.editDiv = document.createElement('div') this.editDiv.innerHTML = ` -
Label:
+
Label
` - this.editDiv.querySelector('input').value = this.data.Attributes.Label + this.editDiv.querySelector('input').value = this.data.Attributes.Label || '[set label]' return [this.editDiv] }// }}} - async editUpdate() {// {{{ - console.log('hum') + async updateWidget() {// {{{ this.data.Attributes.Label = this.editDiv.querySelector('input').value + return {} }// }}} }// }}} class SmonWidgetOnOff extends SmonWidget {// {{{ @@ -397,41 +764,42 @@ class SmonWidgetOnOff extends SmonWidget {// {{{ let img switch (this.value) { - case 1: - case 'ON': - img = 'widget_light_green.svg' - break - 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 + this.elLabel.innerText = this.data.Attributes.Label || '[set label]' this.applyLabelAttributes(this.elLabel) }// }}} editWidget() {// {{{ this.editDiv = document.createElement('div') this.editDiv.innerHTML = ` -
Label:
+
Label
` - this.editDiv.querySelector('input').value = this.data.Attributes.Label + this.editDiv.querySelector('input').value = this.data.Attributes.Label || '[set label]' return [this.editDiv] }// }}} - async editUpdate() {// {{{ + 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) From 8b51b444f60552f602466891dc3a6cfca5aeb680 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Magnus=20=C3=85hall?= Date: Fri, 21 Aug 2026 11:18:03 +0200 Subject: [PATCH 4/4] Bumped to v42 --- main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/main.go b/main.go index 0cb8574..4094aec 100644 --- a/main.go +++ b/main.go @@ -29,7 +29,7 @@ import ( "time" ) -const VERSION = "v41" +const VERSION = "v42" var ( logger *slog.Logger