Over the past few weeks I've been working on a new dashboard. Initially my goal was to throw something together I could use to replace my use of the standard NR dashboard because I've outgrown it. But as I dove into it, it became a fun project and so I'm ending up building far more than I initially expected. In part, I also realized that if I want to use this for the next decade (I've been using NR for 5-6 years by now) I better build it so its use doesn't require much javascript, html, or css knowledge, because I'm bound to forget it all again over that time frame
Currently there's a demo available at FlexDash which runs entirely in the browser (no node-red connection) and displays random data. This demo has all the code to connect to NR but for now it's easier to work with generated data. Keep in mind that this is very preliminary.
I thought I'd start this thread as a way for me to record design decisions and perhaps to get some input. In the end, what I'm trying to end up with is three-fold: a new dashboard for myself, a dashboard that is not tied to node-red so I can use it elsewhere too, and a dashboard that others can use and contribute to as well. As you can see, I'm quite selfish
Before diving in I want to acknowledge that nothing here is new. It's all existing ideas rehashed, I'm just documenting which ideas I decided to borrow.
Self-contained Dashboard
The most important design choice I made is that FlexDash is self-contained by which I mean that the entire life-cycle, from creating, configuring, to operating happens in the FlexDash code in the browser. A server (or multiple servers) primarily just feed data to be displayed and receive user input events in return when the user flips a switch or moves a slider. A server is also needed to save (i.e. persist) the configuration of the dashboard, but in principle this could also be done by saving the config in local (browser, etc) storage.
This design is in contrast to the current dashboard where the configuration is edited in Node-Red and, once saved, is pushed to the browsers displaying the dashboard. In that model there are two somewhat distinct pieces of code: the code to display the dashboard and the pieces of code attached to NR UI nodes to configure how these nodes are to be shown.
The reason for the new architecture is two-fold. First it makes the dashboard more independent of NR, which has benefits such as being able to display data coming from other sources like MQTT or directly from a microcontroller. Second it makes for a more interactive and responsive user experience in that the user can manipulate the UI components directly on-screen and immediately see what the effect of a change is.
An obvious downside of this architecture is that editing no longer happens entirely in NR and, perhaps more significantly, that the dashboard is conceptually more removed from NR than with the current model. Specifically, the user no longer places specific UI elements, such as switches, charts, or gauges into the NR flows. How data in NR is linked to the dashboard leads to the second most important design decision, which is how data flows to the dashboard.
As an aside, the design doesn't entirely preclude having specific UI nodes in NR, it just makes it unnecessary and perhaps more cumbersome than not having them. Ultimately this is probably mostly a matter of personal preference.
Data flow via a topic hierarchy
Data produced in Node-Red is bound to UI components displaying that data using a topic tree very similar to MQTT. Many Node-Red messages already have a msg.topic
so this notion should feel familiar to NR users. The basic mechanism is that a NR flow sends a message with a topic and a payload to a generic dashboard node and a corresponding UI component displays the payload value by being "bound" to the same topic (FlexDash uses the term binding inherited from the reactive data binding in Vue but one could use subscription just as well). An example might be a temperature that the flow sends to msg.topic = "home/livingrm/ceiling"
and that a temperature UI element visualizes.
A major benefit of using a topic-based binding is that it becomes possible to vary the number of UI components based on the actual data. For example, a flow could produce values of the form home/temperatures/<room name>
and a special array UI component could be configured in the dashboard to instantiate as many thermometer UI components as there are values under home/temperatures
.
The dashboard design is such that for each input to a UI component the user can choose whether to assign a literal value or whether to bind it to a topic. This means that any input exposed by the UI component can be controlled from NR. An example might be to bind both the title and value of a thermometer to topics so one can rotate which temperature is shown over time. Some of this is also possible in the current NR dashboard, but only where explicitly programmed by the UI component developer.
All this leads in to the implementation architecture...
Implementation architecture
FlexDash is written using the Vue2 web design framework together with the Vuetify component library. For a highly interactive web app the "reactive" frameworks seem to be the way to go and Vue is both widely used and very approachable. The latter is important so users can develop their own UI widgets easily. Vuetify works well with Vue and has a large selection of components that are used to create the dashboard's UI (toolbars, buttons, menus, etc). Bootstrap-vue would probably have been a good choice too. Instead of Vue other frameworks could also have worked. I briefly looked at React and Svelte and wasn't convinced that they would be better for this project but I can't claim any authority on that point.
In the future, it would be great to port FlexDash to Vue3 because that solves some reactivity issues that I've bumped into and that lead me to look whether the grass is greener on the React or Svelte fronts. More importantly Vue3 improves the way components can be written (the "Composition API"), but currently Vue3 is not supported by Vuetify so this has to wait at least a few months.
The design is entirely based on web components as implemented by Vue. There are four levels to the user interface:
- the dashboard as a whole ("the page" or "the app")
- a number of tabs, represented in a conventional manner across the top of the page
- one of multiple grids filling the content portion of a tab
- widgets that are placed into a grid and that display the dashboard data
These UI levels are implemented more or less by corresponding Vue components:
- the dash component, which implements the top-level app frame, the tabs and shows icons for the server connections
- a selection of grid components that implement different strategies for arranging widgets (currently only one is implemented )
- a widget-wrapper component that implements the editing of inputs and the binding to topics
- the actual UI widgets that display data
The last level is often comprised of two layers: a web component or library that someone publishes on the web, such as a charting package or a fancy data table implementation, and a Vue component wrapper around it that integrates it into FlexDash.
Widget API
The most important interface inside the FleshDash architecture is the widget API. This is the contract between widgets that display data and the "upper" layers of FlexDash. Currently the API is still evolving rapidly and not formalized at all. (I also want to add that I'm only familiar with Vue components and ignorant of the web components spec, so if there's something FlexDash should do so it can include non-Vue web components then that would be an interesting topic for discussion. )
An important decision in FlexDash is that components are integrated by introspection. What this means is that FlexDash loads a bunch of components and then queries what they expose to Vue in order to determine how they can be configured in FlexDash. This is a bit dirty in that it reaches into Vue but it seems to be the best simple way to go. Most likely a custom loader that inspects the components before registering them in Vue would be cleaner and could implement more features, but it's more work with unclear benefits at this stage.
The primary effect of the introspection is to drive the layout of the editing panel that the widget wrapper sets-up. It currently looks like this:
The main aspect of a widget component that FlexDash inspects are the properties. These are variables in the component to which Vue can feed values and are used to specify title, color, value, etc. More specifically, a Vue parent component can bind the child component's "props" to its own variables and thereby feed data into the child component. In FlexDash this parent component is the widget wrapper that uses these Vue bindings to feed either literal values or dynamic values from the topic tree into the widget component.
The widget wrapper inspects each individual prop as follows (Vue calls the properties "props"):
- the name of the prop is the label the user sees in the widget editing panel
- the default value is shown as initial value and as tip
- the type declared for the prop drives the type of input element used by the wrapper to configure literal values (e.g. string, number, boolean, color, array, object)
- the type is also used to convert dynamic values, in particular from string to number or to boolean
- the validator is used to show "invalid input" errors to the user while configuring
In the code of a widget this looks as follows:
props: {
arc: { type: Number, default: 90, // degrees spanned by the arc of the gauge
validator: (v) => v>10 && v<=360 },
}
In addition to the Vue standard prop attributes, it seems attractive (i.e. not yet implemented ) to add custom attributes for the following:
- a help string for a larger tooltip explaining the prop
- ordering and grouping directives to drive the layout of the widget editing panel
In addition to inspecting the props of a widget component, "it seems attractive" to add custom options to the component for the following:
- specify output events for the data values the widget can emit to be sent back to the server so the user can bind these to topics (right now the editing panel adds a phoney "_output" prop for that purpose)
- provide a custom rendering function to display a custom editing panel instead of the generic one provided by the widget wrapper
All in all, so far writing a widget wrapper around some existing component or library is rather simple and can be done in one sparse page of code, often less. The biggest headaches and time sinks have actually been dealing with HTML layout tricks to ensure that the visuals fill the available area nicely.
Config change implementation and undo
After coding a couple of "save/cancel" and "do you really want to delete?" interactions I reached the conclusion that I'm better off biting the bullet and implement undo for the configuration edits. This way all actions can be simple and immediate and the user can hit "undo" if something unexpected happened.
The configuration is represented within the topic tree just like other dynamic values but under a special $config
top-level branch. The configuration is organized as a denormalized hierarchical structure: there is a map of tabs, a map of grid, and a map of widgets. The keys of each of these maps are IDs (such as w00233
for a widget) and the values hold the config of an element. The links between the types of elements are by IDs, so for example a grid has a widgets
array that holds the IDs of the widgets that are placed into the grid. A simple config for a dashboard with one tab, one grid, and two widgets might be:
$config.dash = { title: "Home Dashboard", tabs: ["t00001"]}
$config.tabs = {
t00001: { icon: "house", grids: ["g00001"] },
}
$config.grids = {
g00001: { width: 200, height: 120, widgets: ["w00001", "w00002"] },
}
$config.widgets = {
w00001: { kind: "Gauge", title: "Living Room", unit: "F", value: "home/temperatures/livingrm" },
w00002: { kind: "Gauge", title: "Bedroom", unit: "F", value: "home/temperatures/bdrm" }
}
In FlexDash all data items, including the configuration, are held in a store object which exposes mutations, such as addWidget
or changeGrid
. To implement undo, each mutation is coded to create a set of operations to mutate the topic tree to implement the mutation as well as a set of operations to revert it. For example, to add a widget the widgets array of the grid needs to be updated and the new widget's config needs to be entered into the widgets map. To undo this, the grid's widgets array needs to be set to the old value and the widgets map entry deleted.
In terms of server communication, after trying a few approaches the best seems to be to push the mutations asynchronously to the server (although typically the server responds instantaneously). The conceptual model is borrowed from Google Apps: the user sees the change immediately locally while it's pushed to the server in the background and an unobtrusive UI element shows something like "saving..." or "config is saved". If there is a problem, the UI element becomes more prominent and shows some information about the problem. At that point the user can fix the problem or decide to proceed with edits at the risk of not being able to save them.
In addition to the above machinery, it should be easy to add the ability to copy the config to the clipboard as well as import a config from the clipboard, just like in the NR admin for flows.
From the server's point of view, the config updates are mutations of the topic tree indistinguishable from "data" mutations coming in from NR nodes that update values to be displayed or from UI widgets representing user actions. It would be a fairly localized and simple code change to split the config from the data such that data and config can be sent to different servers. However, by combining them it is possible to modify the config programmatically from the server end. For example, the array of thermometer widgets described previously could be implemented by updating the config from the NR end instead of using an array construct on the dashboard side. Editing the config from the NR end also opens the possibility to implement or port the existing UI nodes in that each such node could create and manage its UI counterpart by editing the config.
The initial undo implementation does not take distributed editing into account, i.e., there is no attempt to guard against or merge config changes that happen simultaneously on different browsers or come in from the server end. Given the nature of the dashboard the assumption is that this simplification is OK, at least until proven otherwise.
Customizing FlexDash
The main way to customize FlexDash is expected to be by writing new widgets. In principle these can be loaded from anywhere that the browser can communicate with subject to the standard browser security restrictions. The dashboard itself is loaded from static files and additional widgets can be placed next to those. But it appears possible to confgure Vue such that it includes its "compiler" in the application, which would enable loading additional components dynamically from source code, i.e. with the standard Vue html-javascript-css single-file-component format. This could lower the barrier of entry to developing new components quite low. The biggest limitation to that will be loading dependencies that are used in the component.
The grid is another place where FlexDash can be customized. The initial grid uses the HTML native grid layout. An alternate grid that uses flex layout in the way most web pages are structured could be written as well. But perhaps more interesting would be to write a full custom grid that may or may not use widgets. In the end, all it needs to do is to render html and reach into the store to retrieve the data needed for display plus to update the store for any user input. Given that a tab can support multiple grids that are tiled one below the other, it is also possible to combine standard grids and full custom grids on the the same tab.
The UI styling of FlexDash is kept fairly neutral and could be customized (this is currently awaiting the implementation of a color picker...) The overarching concept is that the visual excitement should come from the data displayed in the widgets and not from the dashboard frame. As a result, all the page elements are kept in whites for the light version and in dark grey for the dark version. A single "primary" color is used to highlight UI elements primarily in editing mode and currently Node-Red's red is used. The plan is to let the user change the primary color as well as inject some color into the title bar. For better or worse, the overall style follows the material design theme in part because that's what Vuetify supports.
Phew, that core dump ended up a lot longer than I planned But better write things down now while they're still fresh than struggle later...