diff --git a/nodes/config/locales/en-US/ui_base.json b/nodes/config/locales/en-US/ui_base.json
index 688ebbc87..0613f1699 100644
--- a/nodes/config/locales/en-US/ui_base.json
+++ b/nodes/config/locales/en-US/ui_base.json
@@ -38,7 +38,9 @@
"page": "Page",
"link": "Link",
"group": "Group",
+ "addGroup": "Add Group",
"edit": "Edit",
+ "layoutEditor": "Layout Editor",
"focus": "Focus",
"collapse": "Collapse",
"expand": "Expand",
diff --git a/nodes/config/locales/en-US/ui_page.json b/nodes/config/locales/en-US/ui_page.json
index fed5b28f8..0465ae0bb 100644
--- a/nodes/config/locales/en-US/ui_page.json
+++ b/nodes/config/locales/en-US/ui_page.json
@@ -7,6 +7,7 @@
"icon": "Icon",
"theme": "Theme",
"layout": "Layout",
+ "wysiwyg": "Editor",
"grid": "Grid",
"fixed": "Fixed",
"tabs": "Tabs",
diff --git a/nodes/config/ui_base.html b/nodes/config/ui_base.html
index 3896ecf87..3b16e4ab9 100644
--- a/nodes/config/ui_base.html
+++ b/nodes/config/ui_base.html
@@ -288,6 +288,7 @@
// #endregion
(function () {
+ const supportedEditableLayouts = ['grid', 'flex']
const sidebarContainer = '
', { class: 'nrdb2-sb-list-header-button-group', id: item.id }).appendTo(parent)
if (!configNodes.includes(item.type)) {
- const focusButton = $('').appendTo(btnGroup)
+ const focusButton = $(``).appendTo(btnGroup)
focusButton.on('click', function (evt) {
RED.view.reveal(item.id)
evt.stopPropagation()
evt.preventDefault()
})
}
- const editButton = $('').appendTo(btnGroup)
+
+ // button to edit node via node-red editor panel
+ const editButton = $(``).appendTo(btnGroup)
editButton.on('click', function (evt) {
if (configNodes.includes(item.type)) {
RED.editor.editConfig('', item.type, item.id)
@@ -849,14 +852,70 @@
evt.stopPropagation()
evt.preventDefault()
})
- if (item.type === 'ui-page') {
+
+ if (item.type === 'ui-page' && supportedEditableLayouts.includes(item.node.layout)) {
+ // button to edit group in wysiwyg layout editor
+ const layoutButton = $(``).appendTo(btnGroup)
+ layoutButton.on('click', async function (evt) {
+ evt.preventDefault()
+ evt.stopPropagation()
+
+ // call to httpadmin endpoint requesting layout editor mode for this group
+ const windowUrl = new URL(window.location.href)
+ const pageId = item.id
+ const baseId = item.node.ui
+ const base = RED.nodes.node(item.node.ui)
+ const dashboardBasePath = (base.path || 'dashboard').replace(/\/$/, '').replace(/^\/+/, '')
+ const authTokens = RED.settings.get('auth-tokens') || {}
+ const headers = {}
+ if (authTokens.access_token) {
+ headers.Authorization = `${authTokens.token_type || 'Bearer'} ${authTokens.access_token}`
+ }
+
+ // promisify the ajax call so we can await it & return false after opening the popup
+ const ajax = () => {
+ return new Promise((resolve, reject) => {
+ $.ajax({
+ url: `${dashboardBasePath}/api/v1/${baseId}/edit/${pageId}`, // e.g. /dashboard/api/v1/123/edit/456
+ type: 'PATCH',
+ headers,
+ data: {
+ mode: 'edit',
+ dashboard: baseId,
+ page: pageId
+ },
+ success: function (data) {
+ // construct the url to open the layout editor
+ const _url = new URL(`${dashboardBasePath}/${data.path}`.replace(/\/\//g, '/'), windowUrl.origin)
+ _url.searchParams.set('edit-key', data.editKey)
+ const editorPath = windowUrl.pathname?.replace(/\/$/, '').replace(/^\/+/, '') || ''
+ if (editorPath) {
+ _url.searchParams.set('editor-path', editorPath)
+ }
+ resolve(_url)
+ },
+ error: function (err) {
+ reject(err)
+ }
+ })
+ })
+ }
+ try {
+ const url = await ajax()
+ const target = `ff-dashboard-${baseId}` // try to reuse the same window per base
+ window.open(url, target)
+ } catch (err) {
+ console.error('layout mode error', err)
+ RED.notify('Unable to begin layout editor', 'error')
+ }
+ return false // return false to click event to prevent default
+ })
// add the "+ group" button
- $('')
- .click(function (evt) {
- list.editableList('addItem')
- evt.preventDefault()
- })
- .appendTo(btnGroup)
+ const groupEditButton = $(``).appendTo(btnGroup)
+ groupEditButton.on('click', function (evt) {
+ list.editableList('addItem')
+ evt.preventDefault()
+ })
}
}
diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js
index 2f2c3bbfd..9d3bffe4a 100644
--- a/nodes/config/ui_base.js
+++ b/nodes/config/ui_base.js
@@ -1,5 +1,7 @@
const path = require('path')
+const axios = require('axios')
+
const v = require('../../package.json').version
const datastore = require('../store/data.js')
const statestore = require('../store/state.js')
@@ -435,9 +437,17 @@ module.exports = function (RED) {
node.ui.groups.set(id, { ...group, ...state })
}
}
-
+ // if the socket has an editKey & it matches the editKey in the meta, then we will
+ // send the wysiwyg meta to the client. Otherwise, we will remove it from the meta
+ // before sending it to the client
+ const handshakeEditKey = socket.handshake?.query?.editKey
+ const meta = { ...node.ui.meta }
+ if (!handshakeEditKey || !meta?.wysiwyg?.editKey || handshakeEditKey !== meta.wysiwyg.editKey) {
+ delete meta.wysiwyg
+ }
// pass the connected UI the UI config
socket.emit('ui-config', node.id, {
+ meta,
dashboards: Object.fromEntries(node.ui.dashboards),
heads: Object.fromEntries(node.ui.heads),
pages: Object.fromEntries(node.ui.pages),
@@ -778,6 +788,15 @@ module.exports = function (RED) {
*/
// store ui config to be sent to UI
node.ui = {
+ meta: {
+ wysiwyg: {
+ enabled: false,
+ timestamp: null,
+ dashboard: null,
+ page: null,
+ editKey: null
+ }
+ },
heads: new Map(),
dashboards: new Map(),
pages: new Map(),
@@ -1083,19 +1102,16 @@ module.exports = function (RED) {
}
node.ui.widgets.delete(widgetNode.id)
changes = true
- }
-
- // if there are no more widgets on this group, remove the group from our UI config
- if (group && [...node.ui.widgets].filter(w => w.props?.group === group.id).length === 0) {
+ } else if (group) {
+ // remove group from our UI config
node.ui.groups.delete(group.id)
changes = true
- }
-
- // if there are no more groups on this page, remove the page from our UI config
- if (page && [...node.ui.groups].filter(g => g.page === page.id).length === 0) {
+ } else if (page) {
+ // remove page from our UI config
node.ui.pages.delete(page.id)
changes = true
}
+
if (changes) {
node.requestEmitConfig()
}
@@ -1108,4 +1124,151 @@ module.exports = function (RED) {
}
RED.nodes.registerType('ui-base', UIBaseNode)
+
+ // PATCH: /dashboard/api/v1/:dashboardId/flows - deploy curated/controlled updates to the flows
+ RED.httpAdmin.patch('/dashboard/api/v1/:dashboardId/flows', RED.auth.needsPermission('flows.write'), async function (req, res) {
+ const host = RED.settings.uiHost
+ const port = RED.settings.uiPort
+ const httpAdminRoot = RED.settings.httpAdminRoot
+ const url = 'http://' + (`${host}:${port}/${httpAdminRoot}flows`).replace('//', '/')
+ console.log('url', url)
+ // get request body
+ const dashboardId = req.params.dashboardId
+ const pageId = req.body.page
+ const changes = req.body.changes || {}
+ const editKey = req.body.key
+ const groups = changes.groups || []
+ console.log(changes, editKey, dashboardId)
+ const baseNode = RED.nodes.getNode(dashboardId)
+
+ // validity checks
+ if (groups.length === 0) {
+ // this could be a 200 but since the group data might be missing due to
+ // a bug or regression, we'll return a 400 and let the user know
+ // there were no changes provided.
+ return res.status(400).json({ error: 'No changes to deploy' })
+ }
+ if (!baseNode) {
+ return res.status(404).json({ error: 'Dashboard not found' })
+ }
+ if (!baseNode.ui.meta.wysiwyg.enabled) {
+ return res.status(403).json({ error: 'Unauthorized' })
+ }
+ if (editKey !== baseNode.ui.meta.wysiwyg.editKey) {
+ return res.status(403).json({ error: 'Unauthorized' })
+ }
+ if (pageId !== baseNode.ui.meta.wysiwyg.page) {
+ return res.status(403).json({ error: 'Unauthorized' })
+ }
+ for (const modified of groups) {
+ if (modified.page !== baseNode.ui.meta.wysiwyg.page) {
+ return res.status(400).json({ error: 'Invalid page id' })
+ }
+ }
+
+ // Prepare headers for the requests
+ const getHeaders = {
+ 'Node-RED-API-Version': 'v2',
+ Accept: 'application/json'
+ }
+ const postHeaders = {
+ 'Node-RED-Deployment-Type': 'nodes', // only update the nodes (don't restart ALL nodes! Only those that have changed)
+ 'Node-RED-API-Version': 'v2',
+ 'Content-Type': 'application/json'
+ }
+ // apply headers from the incoming request
+ if (req.headers.cookie) {
+ getHeaders.cookie = req.headers.cookie
+ postHeaders.cookie = req.headers.cookie
+ }
+ if (req.headers.authorization) {
+ getHeaders.authorization = req.headers.authorization
+ postHeaders.authorization = req.headers.authorization
+ }
+ if (req.headers.referer) {
+ getHeaders.referer = req.headers.referer
+ postHeaders.referer = req.headers.referer
+ }
+
+ const applyIfDifferent = (node, nodeNew, propName) => {
+ const origValue = node[propName]
+ const newValue = nodeNew[propName]
+ if (origValue !== newValue) {
+ node[propName] = newValue
+ return true
+ }
+ return false
+ }
+ let rev = null
+ return axios.request({
+ method: 'GET',
+ headers: getHeaders,
+ url
+ }).then(response => {
+ const flows = response.data?.flows || []
+ rev = response.data?.rev
+ const changeResult = []
+ for (const modified of groups) {
+ const current = flows.find(n => n.id === modified.id)
+ if (!current) {
+ // group not found in current flows! integrity of data suspect! Has flows changed on the server?
+ return res.status(400).json({ error: 'Group not found', code: 'GROUP_NOT_FOUND' })
+ }
+ if (modified.page !== current.page) {
+ // integrity of data suspect! Has flow changed on the server?
+ return res.status(400).json({ error: 'Invalid page id', code: 'INVALID_PAGE_ID' })
+ }
+ changeResult.push(applyIfDifferent(current, modified, 'width'))
+ changeResult.push(applyIfDifferent(current, modified, 'order'))
+ }
+ if (changeResult.length === 0 || !changeResult.includes(true)) {
+ return res.status(200).json({ message: 'No changes were' })
+ }
+ return flows
+ }).then(flows => {
+ // update the flows with the new group order
+ return axios.request({
+ method: 'POST',
+ headers: postHeaders,
+ url,
+ data: {
+ flows,
+ rev
+ }
+ })
+ }).then(response => {
+ return res.status(200).json(response.data)
+ }).catch(error => {
+ console.error(error)
+ const status = error.response?.status || 500
+ return res.status(status).json({ error: error.message })
+ })
+ })
+
+ // PATCH: /dashboard/api/v1/:dashboardId/edit/:pageId - start editing a page
+ RED.httpAdmin.patch('/dashboard/api/v1/:dashboardId/edit/:pageId', RED.auth.needsPermission('flows.write'), async function (req, res) {
+ /** @type {UIBaseNode} */
+ const baseNode = RED.nodes.getNode(req.params.dashboardId)
+ if (!baseNode) {
+ return res.status(404).json({ error: 'Dashboard not found' })
+ }
+ const pageNode = baseNode.ui.pages.get(req.params.pageId)
+ if (!pageNode) {
+ return res.status(404).json({ error: 'Page not found' })
+ }
+ const editConfig = {
+ timestamp: Date.now(),
+ path: pageNode.path || '',
+ dashboard: baseNode.id,
+ page: pageNode.id,
+ editKey: Math.random().toString(36).substring(2)
+ }
+ baseNode.ui.meta.wysiwyg.enabled = true
+ baseNode.ui.meta.wysiwyg.timestamp = editConfig.timestamp
+ baseNode.ui.meta.wysiwyg.editKey = editConfig.editKey
+ baseNode.ui.meta.wysiwyg.dashboard = baseNode.id
+ baseNode.ui.meta.wysiwyg.page = pageNode.id
+
+ return res.status(200).json(editConfig)
+ })
}
diff --git a/nodes/config/ui_page.html b/nodes/config/ui_page.html
index 8d8fbbfb7..b4bdff3a9 100644
--- a/nodes/config/ui_page.html
+++ b/nodes/config/ui_page.html
@@ -97,6 +97,7 @@
types: [{
value: 'layout',
options: [
+ { value: 'wysiwyg', label: RED._('@flowfuse/node-red-dashboard/ui-page:ui-page.label.wysiwyg') },
{ value: 'grid', label: RED._('@flowfuse/node-red-dashboard/ui-page:ui-page.label.grid') },
{ value: 'flex', label: RED._('@flowfuse/node-red-dashboard/ui-page:ui-page.label.fixed') },
{ value: 'tabs', label: RED._('@flowfuse/node-red-dashboard/ui-page:ui-page.label.tabs') },
diff --git a/nodes/widgets/ui_button.html b/nodes/widgets/ui_button.html
index e965f70af..925e3d4c9 100644
--- a/nodes/widgets/ui_button.html
+++ b/nodes/widgets/ui_button.html
@@ -17,7 +17,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
diff --git a/nodes/widgets/ui_button_group.html b/nodes/widgets/ui_button_group.html
index 1254a1284..94ca11fae 100644
--- a/nodes/widgets/ui_button_group.html
+++ b/nodes/widgets/ui_button_group.html
@@ -15,7 +15,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
diff --git a/nodes/widgets/ui_chart.html b/nodes/widgets/ui_chart.html
index 1cdeea74c..226889167 100644
--- a/nodes/widgets/ui_chart.html
+++ b/nodes/widgets/ui_chart.html
@@ -134,7 +134,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
diff --git a/nodes/widgets/ui_dropdown.html b/nodes/widgets/ui_dropdown.html
index f40cc2ed2..5c06df3fd 100644
--- a/nodes/widgets/ui_dropdown.html
+++ b/nodes/widgets/ui_dropdown.html
@@ -18,7 +18,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
@@ -54,13 +54,13 @@
}
if (this.chips === undefined) {
$('#node-input-chips').prop('checked', false)
- }
+ }
if (this.clearable === undefined) {
$('#node-input-clearable').prop('checked', false)
- }
+ }
if (this.typeIsComboBox === undefined) {
$('#node-input-typeIsComboBox').prop('checked', true)
- }
+ }
// if this groups parent is a subflow template, set the node-config-input-width and node-config-input-height up
// as typedInputs and hide the elementSizer (as it doesn't make sense for subflow templates)
if (RED.nodes.subflow(this.z)) {
diff --git a/nodes/widgets/ui_file_input.html b/nodes/widgets/ui_file_input.html
index 8fe5763be..4130b98ae 100644
--- a/nodes/widgets/ui_file_input.html
+++ b/nodes/widgets/ui_file_input.html
@@ -16,7 +16,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
diff --git a/nodes/widgets/ui_form.html b/nodes/widgets/ui_form.html
index 441211da9..b2bfa318a 100644
--- a/nodes/widgets/ui_form.html
+++ b/nodes/widgets/ui_form.html
@@ -21,7 +21,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
diff --git a/nodes/widgets/ui_gauge.html b/nodes/widgets/ui_gauge.html
index b1d680672..85c49a635 100644
--- a/nodes/widgets/ui_gauge.html
+++ b/nodes/widgets/ui_gauge.html
@@ -129,7 +129,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
diff --git a/nodes/widgets/ui_number_input.html b/nodes/widgets/ui_number_input.html
index b7a4bc8b5..7ff748ab8 100644
--- a/nodes/widgets/ui_number_input.html
+++ b/nodes/widgets/ui_number_input.html
@@ -17,7 +17,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
diff --git a/nodes/widgets/ui_radio_group.html b/nodes/widgets/ui_radio_group.html
index 97a40ab36..fab4fa481 100644
--- a/nodes/widgets/ui_radio_group.html
+++ b/nodes/widgets/ui_radio_group.html
@@ -17,7 +17,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
diff --git a/nodes/widgets/ui_slider.html b/nodes/widgets/ui_slider.html
index 6658e9a25..db5440b17 100644
--- a/nodes/widgets/ui_slider.html
+++ b/nodes/widgets/ui_slider.html
@@ -18,7 +18,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
diff --git a/nodes/widgets/ui_switch.html b/nodes/widgets/ui_switch.html
index ecd9c120f..e447e8839 100644
--- a/nodes/widgets/ui_switch.html
+++ b/nodes/widgets/ui_switch.html
@@ -18,7 +18,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
diff --git a/nodes/widgets/ui_table.html b/nodes/widgets/ui_table.html
index 2bc40eab0..90a2858ab 100644
--- a/nodes/widgets/ui_table.html
+++ b/nodes/widgets/ui_table.html
@@ -72,7 +72,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
diff --git a/nodes/widgets/ui_template.html b/nodes/widgets/ui_template.html
index 058e15659..7953e274d 100644
--- a/nodes/widgets/ui_template.html
+++ b/nodes/widgets/ui_template.html
@@ -135,15 +135,15 @@
width: {
value: 0,
validate: function (v) {
- let valid = true
if (this.templateScope !== 'global') {
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
+ return valid
}
- return valid
+ return true
}
},
height: { value: 0 },
diff --git a/nodes/widgets/ui_text.html b/nodes/widgets/ui_text.html
index 55a8f2fbb..8e05630ce 100644
--- a/nodes/widgets/ui_text.html
+++ b/nodes/widgets/ui_text.html
@@ -83,7 +83,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
diff --git a/nodes/widgets/ui_text_input.html b/nodes/widgets/ui_text_input.html
index c010be80b..5029f5044 100644
--- a/nodes/widgets/ui_text_input.html
+++ b/nodes/widgets/ui_text_input.html
@@ -17,7 +17,7 @@
const width = v || 0
const currentGroup = $('#node-input-group').val() || this.group
const groupNode = RED.nodes.node(currentGroup)
- const valid = !groupNode || +width <= +groupNode.width
+ const valid = !groupNode || +width >= 0
$('#node-input-size').toggleClass('input-error', !valid)
return valid
}
diff --git a/package-lock.json b/package-lock.json
index cabbaa9c2..21d616a09 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"license": "Apache-2.0",
"dependencies": {
"acorn": "^8.11.2",
+ "axios": "^1.7.7",
"chartjs-adapter-luxon": "^1.3.1",
"core-js": "^3.32.0",
"d3": "^7.8.5",
@@ -4697,8 +4698,7 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
- "dev": true
+ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/at-least-node": {
"version": "1.0.0",
@@ -4740,10 +4740,9 @@
"dev": true
},
"node_modules/axios": {
- "version": "1.7.4",
- "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz",
- "integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==",
- "dev": true,
+ "version": "1.7.7",
+ "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz",
+ "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
@@ -4754,7 +4753,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
- "dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@@ -4767,8 +4765,7 @@
"node_modules/axios/node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
- "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
- "dev": true
+ "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
},
"node_modules/b4a": {
"version": "1.6.6",
@@ -5783,7 +5780,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -6897,7 +6893,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
- "dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -8404,7 +8399,6 @@
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
- "dev": true,
"funding": [
{
"type": "individual",
diff --git a/package.json b/package.json
index b82f89a6e..b442cb36a 100644
--- a/package.json
+++ b/package.json
@@ -54,6 +54,7 @@
},
"dependencies": {
"acorn": "^8.11.2",
+ "axios": "^1.7.7",
"chartjs-adapter-luxon": "^1.3.1",
"core-js": "^3.32.0",
"d3": "^7.8.5",
diff --git a/ui/src/App.vue b/ui/src/App.vue
index b8afbb0d9..bc45c2252 100644
--- a/ui/src/App.vue
+++ b/ui/src/App.vue
@@ -20,6 +20,7 @@
+
+
diff --git a/ui/src/layouts/Baseline.vue b/ui/src/layouts/Baseline.vue
index 354016f50..220089737 100644
--- a/ui/src/layouts/Baseline.vue
+++ b/ui/src/layouts/Baseline.vue
@@ -6,7 +6,9 @@
- {{ pageTitle }}
+ {{ pageTitle }}
+ mdi-pencil
+
@@ -37,7 +39,11 @@
:to="page.type === 'ui-page' ? { name: page.route.name } : null"
:data-nav="page.id"
@click="closeNavigationDrawer()"
- />
+ >
+
+
+
+
@@ -65,6 +71,8 @@
+
+
diff --git a/ui/src/layouts/Flex.vue b/ui/src/layouts/Flex.vue
index cd97869e5..0b299f3bd 100644
--- a/ui/src/layouts/Flex.vue
+++ b/ui/src/layouts/Flex.vue
@@ -2,23 +2,31 @@
@@ -47,33 +55,67 @@
+