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 }}

+