Skip to content

Commit

Permalink
Improvements to history chart; dynamic update of table and chart.
Browse files Browse the repository at this point in the history
  • Loading branch information
hspaay committed Jul 24, 2024
1 parent f7c31c2 commit d6fe1af
Show file tree
Hide file tree
Showing 15 changed files with 518 additions and 43 deletions.
1 change: 1 addition & 0 deletions bindings/hiveoview/src/service/HiveovService.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ func (svc *HiveovService) createRoutes(router *chi.Mux, rootPath string) http.Ha

// History view. Optional query params 'timestamp' and 'duration'
r.Get("/history/{thingID}/{key}", history.RenderHistoryPage)
r.Get("/latestValue/{thingID}/{key}", history.RenderHistoryLatest)

// Status components
r.Get("/status", status.RenderStatus)
Expand Down
2 changes: 1 addition & 1 deletion bindings/hiveoview/src/session/ClientSession.go
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ func (cs *ClientSession) onMessage(msg *things.ThingMessage) (stat hubclient.Del
thingAddr := msg.ThingID
cs.SendSSE(thingAddr, "")
} else if msg.Key == vocab.EventTypeProperties {
// Publish an sse event for each of the properties
// Publish a sse event for each of the properties
// The UI that displays this event can use this as a trigger to load the
// property value:
// hx-trigger="sse:{{.Thing.ThingID}}/{{k}}"
Expand Down
1 change: 0 additions & 1 deletion bindings/hiveoview/src/static/chartjs-4.4.1.umd.js.map

This file was deleted.

7 changes: 7 additions & 0 deletions bindings/hiveoview/src/static/chartjs-adapter-luxon-1.3.1.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions bindings/hiveoview/src/static/luxon-3.4.4.min.js

Large diffs are not rendered by default.

290 changes: 290 additions & 0 deletions bindings/hiveoview/src/static/sse-2.2.1.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
/*
Server Sent Events Extension
============================
This extension adds support for Server Sent Events to htmx. See /www/extensions/sse.md for usage instructions.
*/

(function() {
/** @type {import("../htmx").HtmxInternalApi} */
var api

htmx.defineExtension('sse', {

/**
* Init saves the provided reference to the internal HTMX API.
*
* @param {import("../htmx").HtmxInternalApi} api
* @returns void
*/
init: function(apiRef) {
// store a reference to the internal API.
api = apiRef

// set a function in the public API for creating new EventSource objects
if (htmx.createEventSource == undefined) {
htmx.createEventSource = createEventSource
}
},

getSelectors: function() {
return ['[sse-connect]', '[data-sse-connect]', '[sse-swap]', '[data-sse-swap]']
},

/**
* onEvent handles all events passed to this extension.
*
* @param {string} name
* @param {Event} evt
* @returns void
*/
onEvent: function(name, evt) {
var parent = evt.target || evt.detail.elt
switch (name) {
case 'htmx:beforeCleanupElement':
var internalData = api.getInternalData(parent)
// Try to remove remove an EventSource when elements are removed
var source = internalData.sseEventSource
if (source) {
api.triggerEvent(parent, 'htmx:sseClose', {
source,
type: 'nodeReplaced',
})
internalData.sseEventSource.close()
}

return

// Try to create EventSources when elements are processed
case 'htmx:afterProcessNode':
ensureEventSourceOnElement(parent)
}
}
})

/// ////////////////////////////////////////////
// HELPER FUNCTIONS
/// ////////////////////////////////////////////

/**
* createEventSource is the default method for creating new EventSource objects.
* it is hoisted into htmx.config.createEventSource to be overridden by the user, if needed.
*
* @param {string} url
* @returns EventSource
*/
function createEventSource(url) {
return new EventSource(url, { withCredentials: true })
}

/**
* registerSSE looks for attributes that can contain sse events, right
* now hx-trigger and sse-swap and adds listeners based on these attributes too
* the closest event source
*
* @param {HTMLElement} elt
*/
function registerSSE(elt) {
// Add message handlers for every `sse-swap` attribute
if (api.getAttributeValue(elt, 'sse-swap')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}

// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource

var sseSwapAttr = api.getAttributeValue(elt, 'sse-swap')
var sseEventNames = sseSwapAttr.split(',')

for (var i = 0; i < sseEventNames.length; i++) {
const sseEventName = sseEventNames[i].trim()
const listener = function(event) {
// If the source is missing then close SSE
if (maybeCloseSSESource(sourceElement)) {
return
}

// If the body no longer contains the element, remove the listener
if (!api.bodyContains(elt)) {
source.removeEventListener(sseEventName, listener)
return
}

// swap the response into the DOM and trigger a notification
if (!api.triggerEvent(elt, 'htmx:sseBeforeMessage', event)) {
return
}
swap(elt, event.data)
api.triggerEvent(elt, 'htmx:sseMessage', event)
}

// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(sseEventName, listener)
}
}

// Add message handlers for every `hx-trigger="sse:*"` attribute
if (api.getAttributeValue(elt, 'hx-trigger')) {
// Find closest existing event source
var sourceElement = api.getClosestMatch(elt, hasEventSource)
if (sourceElement == null) {
// api.triggerErrorEvent(elt, "htmx:noSSESourceError")
return null // no eventsource in parentage, orphaned element
}

// Set internalData and source
var internalData = api.getInternalData(sourceElement)
var source = internalData.sseEventSource

var triggerSpecs = api.getTriggerSpecs(elt)
triggerSpecs.forEach(function(ts) {
if (ts.trigger.slice(0, 4) !== 'sse:') {
return
}

var listener = function (event) {
if (maybeCloseSSESource(sourceElement)) {
return
}
if (!api.bodyContains(elt)) {
source.removeEventListener(ts.trigger.slice(4), listener)
}
// Trigger events to be handled by the rest of htmx
htmx.trigger(elt, ts.trigger, event)
htmx.trigger(elt, 'htmx:sseMessage', event)
}

// Register the new listener
api.getInternalData(elt).sseEventListener = listener
source.addEventListener(ts.trigger.slice(4), listener)
})
}
}

/**
* ensureEventSourceOnElement creates a new EventSource connection on the provided element.
* If a usable EventSource already exists, then it is returned. If not, then a new EventSource
* is created and stored in the element's internalData.
* @param {HTMLElement} elt
* @param {number} retryCount
* @returns {EventSource | null}
*/
function ensureEventSourceOnElement(elt, retryCount) {
if (elt == null) {
return null
}

// handle extension source creation attribute
if (api.getAttributeValue(elt, 'sse-connect')) {
var sseURL = api.getAttributeValue(elt, 'sse-connect')
if (sseURL == null) {
return
}

ensureEventSource(elt, sseURL, retryCount)
}

registerSSE(elt)
}

function ensureEventSource(elt, url, retryCount) {
var source = htmx.createEventSource(url)

source.onerror = function(err) {
// Log an error event
api.triggerErrorEvent(elt, 'htmx:sseError', { error: err, source })

// If parent no longer exists in the document, then clean up this EventSource
if (maybeCloseSSESource(elt)) {
return
}

// Otherwise, try to reconnect the EventSource
if (source.readyState === EventSource.CLOSED) {
retryCount = retryCount || 0
retryCount = Math.max(Math.min(retryCount * 2, 128), 1)
var timeout = retryCount * 500
window.setTimeout(function() {
ensureEventSourceOnElement(elt, retryCount)
}, timeout)
}
}

source.onopen = function(evt) {
api.triggerEvent(elt, 'htmx:sseOpen', { source })

if (retryCount && retryCount > 0) {
const childrenToFix = elt.querySelectorAll("[sse-swap], [data-sse-swap], [hx-trigger], [data-hx-trigger]")
for (let i = 0; i < childrenToFix.length; i++) {
registerSSE(childrenToFix[i])
}
// We want to increase the reconnection delay for consecutive failed attempts only
retryCount = 0
}
}

api.getInternalData(elt).sseEventSource = source


var closeAttribute = api.getAttributeValue(elt, "sse-close");
if (closeAttribute) {
// close eventsource when this message is received
source.addEventListener(closeAttribute, function() {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'message',
})
source.close()
});
}
}

/**
* maybeCloseSSESource confirms that the parent element still exists.
* If not, then any associated SSE source is closed and the function returns true.
*
* @param {HTMLElement} elt
* @returns boolean
*/
function maybeCloseSSESource(elt) {
if (!api.bodyContains(elt)) {
var source = api.getInternalData(elt).sseEventSource
if (source != undefined) {
api.triggerEvent(elt, 'htmx:sseClose', {
source,
type: 'nodeMissing',
})
source.close()
// source = null
return true
}
}
return false
}


/**
* @param {HTMLElement} elt
* @param {string} content
*/
function swap(elt, content) {
api.withExtensions(elt, function(extension) {
content = extension.transformResponse(content, null, elt)
})

var swapSpec = api.getSwapSpecification(elt)
var target = api.getTarget(elt)
api.swap(target, content, swapSpec)
}


function hasEventSource(node) {
return api.getInternalData(node).sseEventSource != null
}
})()
9 changes: 8 additions & 1 deletion bindings/hiveoview/src/views/base.gohtml
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,15 @@

<!-- download htmx from: https://htmx.org/ -->
<script src="/static/[email protected]"></script>

{{/* use chartjs for time series display. include the date libraries*/}}
<script src="/static/chartjs-4.4.1.umd.js"></script>
<script src="/static/luxon-3.4.4.min.js"></script>
<script src="/static/chartjs-adapter-luxon-1.3.1.js"></script>


<!-- download htmx sse extension from: https://extensions.htmx.org/ -->
<script src="/static/sse-2.2.0.js"></script>
<script src="/static/sse-2.2.1.js"></script>
<!-- download iconify-icon from: -->
<script src="/static/iconify-icon.min.js"></script>

Expand Down
64 changes: 64 additions & 0 deletions bindings/hiveoview/src/views/history/RenderAddLatestToHistory.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package history

import (
"errors"
"fmt"
"github.com/go-chi/chi/v5"
"github.com/hiveot/hub/api/go/digitwin"
"github.com/hiveot/hub/api/go/vocab"
"github.com/hiveot/hub/bindings/hiveoview/src/session"
"github.com/hiveot/hub/lib/things"
"net/http"
)

// Add the latest event value to the history table and to the history chart
// This returns a html fragment with the table entry and some JS code to update chartjs.

const addRowTemplate = `
<li>
<div>%s</div>
<div>%v</div>
</li>
`

// RenderHistoryLatest renders a single table row with the 'latest' value.
//
// This is supposed to be temporary until events contain all message data
// and a JS function can format the table row, instead of calling the server.
//
// This is a stopgap for now.
//
// @param thingID to view
// @param key whose value to return
func RenderHistoryLatest(w http.ResponseWriter, r *http.Request) {
thingID := chi.URLParam(r, "thingID")
key := chi.URLParam(r, "key")

// Read the TD being displayed and its latest values
mySession, hc, err := session.GetSessionFromContext(r)
if err != nil {
mySession.WriteError(w, err, 0)
return
}

//latestValues, err := thing.GetLatest(thingID, hc)
latestEvents, err := digitwin.OutboxReadLatest(
hc, []string{key}, vocab.MessageTypeEvent, "", thingID)
if err != nil {
mySession.WriteError(w, err, 0)
return
}
evmap, err := things.NewThingMessageMapFromSource(latestEvents)
if err == nil {
tm := evmap[key]
if tm != nil {
fragment := fmt.Sprintf(addRowTemplate,
tm.GetUpdated("WT"), tm.Data)

_, _ = w.Write([]byte(fragment))
return
}
err = errors.New("cant find key: " + key)
}
mySession.WriteError(w, err, 0)
}
Loading

0 comments on commit d6fe1af

Please sign in to comment.