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 = ` + + +
+ + + + +
+ +
+
+
+ + +
Cell operations
+
+
+ + + + +
+ +
+ + + + +
+ +
+ + + +
+ +
+
+
+ +
+ +
+ ` + }// }}} + 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 = ` +
+
+
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. + 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
+
+
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 {// {{{ + 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 }}