Added start of editable dashboard

This commit is contained in:
Magnus Åhall 2026-08-20 11:13:03 +02:00
parent 1af3f59b1a
commit c3fe5951d4
8 changed files with 502 additions and 92 deletions

View file

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

View file

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

2
go.mod
View file

@ -1,6 +1,6 @@
module smon
go 1.25.0
go 1.26.0
require (
git.ahall.se/go/html_template v0.1.1

46
main.go
View file

@ -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")

7
sql/00028.sql Normal file
View file

@ -0,0 +1,7 @@
CREATE TABLE public.dashboard (
id serial NOT NULL,
"name" varchar NOT NULL,
widgets jsonb DEFAULT '{}' NOT NULL,
CONSTRAINT dashboard_pk PRIMARY KEY (id),
CONSTRAINT dashboard_name_unique UNIQUE ("name")
);

View file

@ -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">
<circle
style="fill:#d41400;stroke:none;stroke-width:1.00012;fill-opacity:1"
style="fill:#ff2a2a;stroke:none;stroke-width:0.79399899;fill-opacity:1"
id="circle1"
cx="4.2333331"
cy="4.2333331"

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Before After
Before After

View file

@ -1,91 +1,373 @@
import { CustomHTMLElement } from './lib/custom_html_element.mjs'
export class SmonDashboard extends CustomHTMLElement {
static {
export class SmonDashboard extends CustomHTMLElement {// {{{
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
: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;
}
}
</style>
<div style="display: flex; gap: 8px;">
<button data-el="edit-dashboard">Edit dashboard</button>
<button class="only-on-edit" data-el="add-columns">Add columns</button>
<button class="only-on-edit" data-el="add-rows">Add rows</button>
<button class="only-on-edit" data-el="update-dashboard">Update dashboard</button>
</div>
<div data-el="error"></div>
<div data-el="grid">
</div>
<dialog data-el="edit">
<div data-el="edit-components"></div>
<div style="display: flex; gap: 8px; margin-top: 16px;">
<button data-el="edit-update">Update</button>
<button data-el="edit-remove">Remove</button>
</div>
</dialog>
`
}
constructor() {
}// }}}
constructor() {// {{{
super(true)
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 = `
<style>
:host {
min-width: 16px;
min-height: 16px;
}
</style>
`
}// }}}
constructor(x, y) {// {{{
super({
Attributes: {},
X: x,
Y: y,
})
this.classList.add('empty')
this.render()
}// }}}
render() {// {{{
super.render()
}// }}}
editWidget() {// {{{
const div = document.createElement('div')
div.innerHTML = `
<div>Add widget:</div>
<select style="width: 100%; padding: 4px 4px">
<option value="0">Label</option>
<option value="1">On / off</option>
</select>
`
return [div]
}// }}}
}// }}}
class SmonWidgetLabel extends SmonWidget {// {{{
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
.el-label {
font-weight: bold;
font-size: 1.5em;
margin-bottom: 8px;
}
</style>
<div data-el="label"></div>
`
}
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 = `
<div>Label:</div>
<input type="text">
`
this.editDiv.querySelector('input').value = this.data.Attributes.Label
return [this.editDiv]
}// }}}
async editUpdate() {// {{{
console.log('hum')
this.data.Attributes.Label = this.editDiv.querySelector('input').value
}// }}}
}// }}}
class SmonWidgetOnOff extends SmonWidget {// {{{
static {// {{{
this.tmpl = document.createElement('template')
this.tmpl.innerHTML = `
<style>
@ -94,29 +376,33 @@ class SmonWidgetOnOff extends SmonWidget {
grid-template-columns: min-content 1fr;
align-items: center;
grid-gap: 8px;
font-size: 1.25em;
}
img {
height: 24px;
width: 24px;
}
</style>
<img data-el="status">
<div data-el="label"></div>
`
}
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 = `
<div>Label:</div>
<input type="text">
`
this.editDiv.querySelector('input').value = this.data.Attributes.Label
return [this.editDiv]
}// }}}
async editUpdate() {// {{{
this.data.Attributes.Label = this.editDiv.querySelector('input').value
}// }}}
}// }}}
customElements.define("smon-widget", SmonWidget)
customElements.define("smon-widget-empty", SmonWidgetEmpty)
customElements.define("smon-widget-label", SmonWidgetLabel)
customElements.define("smon-widget-onoff", SmonWidgetOnOff)
customElements.define("smon-dashboard", SmonDashboard)

View file

@ -5,14 +5,12 @@
import { } from '/js/{{ .VERSION }}/dashboard.mjs'
</script>
<div style="display: grid; grid-template-columns: repeat(2, min-content); align-items: end; grid-gap: 8px;">
<img src="/images/{{ .VERSION }}/{{ .CONFIG.THEME }}/logo.svg" style="width: 64px; margin-right: 16px;">
<div>
<img src="/images/{{ .VERSION }}/{{ .CONFIG.THEME }}/logo.svg" style="width: 64px; margin-right: 32px;">
<h1 style="margin-bottom: 0px">Smon</h1>
<h2 style="margin-top: 0px">{{ .VERSION }}</h2>
</div>
<div style="display: grid; grid-template-columns: min-content 1fr; align-items: end; grid-gap: 8px;">
<h1>SMon</h1>
<h2>{{ .VERSION }}</h2>
</div>
<smon-dashboard style="margin-top: 32px">