diff --git a/nodes/config/ui_base.js b/nodes/config/ui_base.js index 5a6863cdb..3bfd41bfd 100644 --- a/nodes/config/ui_base.js +++ b/nodes/config/ui_base.js @@ -1,3 +1,4 @@ +const { Agent } = require('https') const path = require('path') const axios = require('axios') @@ -736,7 +737,7 @@ module.exports = function (RED) { // any widgets we hard-code into our front end (e.g ui-notification for connection alerts) will start with ui- // Node-RED built nodes will be a random UUID if (!wNode && !id.startsWith('ui-')) { - console.log('widget does not exist any more') + console.log('widget does not exist in the runtime', id) // TODO: Handle this better for edit-time added nodes (e.g. ui-spacer) return // widget does not exist any more (e.g. deleted from NR and deployed BUT the ui page was not refreshed) } async function handler () { @@ -903,9 +904,9 @@ module.exports = function (RED) { type: widgetConfig.type, props: widgetConfig, layout: { - width: widgetConfig.width || 3, - height: widgetConfig.height || 1, - order: widgetConfig.order || 0 + width: widgetConfig.width || 3, // default width of 3: this must match up with defaults in wysiwyg editing + height: widgetConfig.height || 1, // default height of 1: this must match up with defaults in wysiwyg editing + order: widgetConfig.order || 0 // default order of 0: this must match up with defaults in wysiwyg editing }, state: statestore.getAll(widgetConfig.id), hooks: widgetEvents, @@ -1144,7 +1145,26 @@ module.exports = function (RED) { const host = RED.settings.uiHost const port = RED.settings.uiPort const httpAdminRoot = RED.settings.httpAdminRoot - const url = 'http://' + (`${host}:${port}/${httpAdminRoot}flows`).replace('//', '/') + let scheme = 'http://' + let httpsAgent + if (RED.settings.https) { + let https = RED.settings.https + try { + if (typeof https === 'function') { + // since https() could return a promise / be async, we need to await it + // if however the function is actually sync, JS will auto wrap it in a promise and await it + https = await https() + } + httpsAgent = new Agent({ + rejectUnauthorized: false, + ...(https || {}) + }) + scheme = 'https://' + } catch (error) { + return res.status(500).json({ error: 'Error processing https settings' }) + } + } + const url = scheme + (`${host}:${port}/${httpAdminRoot}flows`).replace('//', '/') console.log('url', url) // get request body const dashboardId = req.params.dashboardId @@ -1152,11 +1172,16 @@ module.exports = function (RED) { const changes = req.body.changes || {} const editKey = req.body.key const groups = changes.groups || [] + const allWidgets = (changes.widgets || []) + const updatedWidgets = allWidgets.filter(w => !w.__DB2_ADD_WIDGET && !w.__DB2_REMOVE_WIDGET) + const addedWidgets = allWidgets.filter(w => !!w.__DB2_ADD_WIDGET).map(w => { delete w.__DB2_ADD_WIDGET; return w }) + const removedWidgets = allWidgets.filter(w => !!w.__DB2_REMOVE_WIDGET).map(w => { delete w.__DB2_REMOVE_WIDGET; return w }) + console.log(changes, editKey, dashboardId) const baseNode = RED.nodes.getNode(dashboardId) // validity checks - if (groups.length === 0) { + if (groups.length === 0 && allWidgets.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. @@ -1180,6 +1205,32 @@ module.exports = function (RED) { } } + for (const widget of updatedWidgets) { + const existingWidget = baseNode.ui.widgets.get(widget.id) + if (!existingWidget) { + return res.status(400).json({ error: 'Widget not found' }) + } + } + + for (const added of addedWidgets) { + // for now, only ui-spacer is supported + if (added.type !== 'ui-spacer') { + return res.status(400).json({ error: 'Cannot add this kind of widget' }) + } + + // check if the widget is being added to a valid group + const group = baseNode.ui.groups.get(added.group) + if (!group) { + return res.status(400).json({ error: 'Invalid group id' }) + } + } + for (const removed of removedWidgets) { + // for now, only ui-spacer is supported + if (removed.type !== 'ui-spacer') { + return res.status(400).json({ error: 'Cannot remove this kind of widget' }) + } + } + // Prepare headers for the requests const getHeaders = { 'Node-RED-API-Version': 'v2', @@ -1213,14 +1264,20 @@ module.exports = function (RED) { } return false } - let rev = null - return axios.request({ - method: 'GET', - headers: getHeaders, - url - }).then(response => { - const flows = response.data?.flows || [] - rev = response.data?.rev + try { + const getResponse = await axios.request({ + method: 'GET', + headers: getHeaders, + httpsAgent, + url + }) + + if (getResponse.status !== 200) { + return res.status(getResponse.status).json({ error: getResponse?.data?.message || 'An error occurred getting flows', code: 'GET_FAILED' }) + } + + const flows = getResponse.data?.flows || [] + const rev = getResponse.data?.rev const changeResult = [] for (const modified of groups) { const current = flows.find(n => n.id === modified.id) @@ -1235,28 +1292,81 @@ module.exports = function (RED) { changeResult.push(applyIfDifferent(current, modified, 'width')) changeResult.push(applyIfDifferent(current, modified, 'order')) } + // scan through the widgets and apply changes (if any) + for (const modified of updatedWidgets) { + const current = flows.find(n => n.id === modified.id) + if (!current) { + // widget not found in current flows! integrity of data suspect! Has flows changed on the server? + return res.status(400).json({ error: 'Widget not found', code: 'WIDGET_NOT_FOUND' }) + } + if (modified.group !== current.group) { + // integrity of data suspect! Has flow changed on the server? + // Currently we dont support moving widgets between groups + return res.status(400).json({ error: 'Invalid group id', code: 'INVALID_GROUP_ID' }) + } + changeResult.push(applyIfDifferent(current, modified, 'order')) + changeResult.push(applyIfDifferent(current, modified, 'width')) + changeResult.push(applyIfDifferent(current, modified, 'height')) + } + + // scan through the added widgets + for (const added of addedWidgets) { + const current = flows.find(n => n.id === added.id) + if (current) { + // widget already exists in current flows! integrity of data suspect! Has flows changed on the server? + return res.status(400).json({ error: 'Widget already exists', code: 'WIDGET_ALREADY_EXISTS' }) + } + // sanitize the added widget (NOTE: only ui-spacer is supported for now & these are the only properties we care about) + const newWidget = { + id: added.id, + type: added.type, + group: added.group, + name: added.name || '', + order: added.order ?? 0, + width: added.width ?? 1, + height: added.height ?? 1, + className: added.className || '' + } + flows.push(newWidget) + changeResult.push(true) + } + for (const removed of removedWidgets) { + const current = flows.find(n => n.id === removed.id) + if (!current) { + // widget not found in current flows! integrity of data suspect! Has flows changed on the server? + return res.status(400).json({ error: 'Widget not found', code: 'WIDGET_NOT_FOUND' }) + } + const index = flows.indexOf(current) + if (index > -1) { + flows.splice(index, 1) + changeResult.push(true) + } + } if (changeResult.length === 0 || !changeResult.includes(true)) { - return res.status(200).json({ message: 'No changes were' }) + return res.status(201).json({ message: 'No changes were found', code: 'NO_CHANGES' }) } - return flows - }).then(flows => { - // update the flows with the new group order - return axios.request({ + + const postResponse = await axios.request({ method: 'POST', headers: postHeaders, + httpsAgent, url, data: { flows, rev } }) - }).then(response => { - return res.status(200).json(response.data) - }).catch(error => { + + if (postResponse.status !== 200) { + return res.status(postResponse.status).json({ error: postResponse?.data?.message || 'An error occurred deploying flows', code: 'POST_FAILED' }) + } + + return res.status(postResponse.status).json(postResponse.data) + } catch (error) { console.error(error) const status = error.response?.status || 500 - return res.status(status).json({ error: error.message }) - }) + return res.status(status).json({ error: error.message || 'An error occurred' }) + } }) // PATCH: /dashboard/api/v1/:dashboardId/edit/:pageId - start editing a page diff --git a/ui/src/EditTracking.js b/ui/src/EditTracking.js index d3114b82a..fc6ecb08d 100644 --- a/ui/src/EditTracking.js +++ b/ui/src/EditTracking.js @@ -6,8 +6,7 @@ const state = reactive({ editPage: '', editMode: false, editorPath: '', // the custom httpAdminRoot path for the NR editor - isTrackingEdits: false, - originalGroups: [] + isTrackingEdits: false }) // Methods @@ -27,28 +26,19 @@ function initialise (editKey, editPage, editorPath) { /** * Start tracking edits */ -function startEditTracking (groups) { +function startEditTracking () { state.isTrackingEdits = true - updateEditTracking(groups) } /** * Stop tracking edits, clear editKey/editPage & exit edit mode */ -function exitEditMode () { +function endEditMode () { state.editKey = '' state.editPage = '' state.editMode = false state.isTrackingEdits = false state.initialised = false - state.originalGroups = [] -} - -/** - * Update the original groups with the current groups - */ -function updateEditTracking (groups) { - state.originalGroups = JSON.parse(JSON.stringify(groups)) } // RO computed props @@ -56,7 +46,6 @@ const editKey = computed(() => state.editKey) const editPage = computed(() => state.editPage) const editMode = computed(() => !!state.editKey && !!state.editPage) const editorPath = computed(() => state.editorPath) -const originalGroups = computed(() => state.originalGroups) const isTrackingEdits = computed(() => state.isTrackingEdits) -export { editKey, editMode, editPage, editorPath, originalGroups, isTrackingEdits, initialise, exitEditMode, startEditTracking, updateEditTracking } +export { editKey, editMode, editPage, editorPath, isTrackingEdits, initialise, startEditTracking, endEditMode } diff --git a/ui/src/api/node-red.js b/ui/src/api/node-red.js index 5f4b19f0e..9cacfccce 100644 --- a/ui/src/api/node-red.js +++ b/ui/src/api/node-red.js @@ -50,10 +50,11 @@ export default { * @param {string} options.page - The page id * @param {string} options.key - The edit key for verification * @param {Array} options.groups - The updated group objects to apply + * @param {Array} options.widgets - The updated widget objects to apply * @returns the axios request */ - deployChanges: async function deployChangesViaHttpAdminEndpoint ({ dashboard, page, groups, key, editorPath }) { - const changes = { groups } + deployChanges: async function deployChangesViaHttpAdminEndpoint ({ dashboard, page, groups, widgets, key, editorPath }) { + const changes = { groups, widgets } return axios.request({ method: 'PATCH', url: getDashboardApiUrl(editorPath || '', dashboard, 'flows'), diff --git a/ui/src/layouts/Flex.vue b/ui/src/layouts/Flex.vue index 2d4b4e954..6ab5476ae 100644 --- a/ui/src/layouts/Flex.vue +++ b/ui/src/layouts/Flex.vue @@ -10,10 +10,10 @@ :class="getGroupClass(g)" :style="{'width': ((rowHeight * 2 * g.width) + 'px')}" :draggable="editMode" - @dragstart="onDragStart($event, $index)" - @dragover="onDragOver($event, $index, g)" - @dragend="onDragEnd($event, $index, g)" - @dragleave="onDragLeave($event, $index, g)" + @dragstart="onGroupDragStart($event, $index, g)" + @dragover="onGroupDragOver($event, $index, g)" + @dragend="onGroupDragEnd($event, $index, g)" + @dragleave="onGroupDragLeave($event, $index, g)" @drop.prevent @dragenter.prevent > @@ -22,7 +22,7 @@ {{ g.name }} @@ -98,6 +98,12 @@ export default { pageWidgets: function () { return this.widgetsByPage(this.$route.meta.id) }, + groupWidgets () { + if (this.editMode) { // mixin property + return (groupId) => this.pageGroupWidgets[groupId] + } + return (groupId) => this.widgetsByGroup(groupId) + }, page: function () { return this.pages[this.$route.meta.id] }, @@ -109,8 +115,9 @@ export default { } }, mounted () { + console.log('flex layout mounted') if (this.editMode) { // mixin property - this.pageGroups = this.getPageGroups() + this.updateEditStateObjects() this.initializeEditTracking() // Mixin method } }, @@ -130,6 +137,21 @@ export default { }) return groups }, + getGroupWidgets (groupId) { + // get widgets for this group (sorted by layout.order) + const widgets = this.widgetsByGroup(groupId) + // only show the widgets that haven't had their "visible" property set to false + .filter((g) => { + if ('visible' in g) { + return g.visible && g.groupType !== 'dialog' + } + return true + }) + .sort((a, b) => { + return a?.layout?.order - b?.layout?.order + }) + return widgets + }, getWidgetClass (widget) { const classes = [] // ensure each widget has a class for its type @@ -154,7 +176,7 @@ export default { classes.push(properties.class) } // dragging interaction classes - const dragDropClass = this.getDragDropClass(group) // Mixin method + const dragDropClass = this.getGroupDragDropClass(group) // Mixin method if (dragDropClass) { classes.push(dragDropClass) } @@ -179,7 +201,8 @@ export default { this.deployChanges({ dashboard: this.page.ui, page: this.page.id, - groups: this.pageGroups + groups: this.pageGroups, + widgets: this.pageGroupWidgets }).then(() => { this.acceptChanges() // Mixin method }).catch((error) => { @@ -197,7 +220,7 @@ export default { }, discardEdits () { this.revertEdits() // Mixin method - this.pageGroups = this.getPageGroups() + this.updateEditStateObjects() }, async leaveEditMode () { let leave = true @@ -218,6 +241,22 @@ export default { this.discardEdits() } this.exitEditMode() // Mixin method + }, + onGroupResize (opts) { + // ensure opts.width is a number and is greater than 0 + if (typeof opts.width !== 'number' || opts.width < 1) { + return + } + this.pageGroups[opts.index].width = opts.width + }, + updateEditStateObjects () { + console.log('updateEditStateObjects') + this.pageGroups = this.getPageGroups() + const pageGroupWidgets = {} + for (const group of this.pageGroups) { + pageGroupWidgets[group.id] = this.getGroupWidgets(group.id) + } + this.pageGroupWidgets = pageGroupWidgets } } } diff --git a/ui/src/layouts/Grid.vue b/ui/src/layouts/Grid.vue index e6afc56ad..3b81e5f29 100644 --- a/ui/src/layouts/Grid.vue +++ b/ui/src/layouts/Grid.vue @@ -10,10 +10,10 @@ :class="getGroupClass(g)" :style="`grid-column-end: span min(${ g.width }, var(--layout-columns)`" :draggable="editMode" - @dragstart="onDragStart($event, $index)" - @dragover="onDragOver($event, $index, g)" - @dragend="onDragEnd($event, $index, g)" - @dragleave="onDragLeave($event, $index, g)" + @dragstart="onGroupDragStart($event, $index, g)" + @dragover="onGroupDragOver($event, $index, g)" + @dragend="onGroupDragEnd($event, $index, g)" + @dragleave="onGroupDragLeave($event, $index, g)" @drop.prevent @dragenter.prevent > @@ -22,7 +22,7 @@ {{ g.name }} @@ -106,11 +106,18 @@ export default { return this.pageGroups } return this.getPageGroups() + }, + groupWidgets () { + if (this.editMode) { // mixin property + return (groupId) => this.pageGroupWidgets[groupId] + } + return (groupId) => this.widgetsByGroup(groupId) } }, mounted () { + console.log('grid layout mounted') if (this.editMode) { // mixin property - this.pageGroups = this.getPageGroups() + this.updateEditStateObjects() this.initializeEditTracking() // Mixin method } }, @@ -142,6 +149,21 @@ export default { } return classes.join(' ') }, + getGroupWidgets (groupId) { + // get widgets for this group (sorted by layout.order) + const widgets = this.widgetsByGroup(groupId) + // only show the widgets that haven't had their "visible" property set to false + .filter((g) => { + if ('visible' in g) { + return g.visible && g.groupType !== 'dialog' + } + return true + }) + .sort((a, b) => { + return a?.layout?.order - b?.layout?.order + }) + return widgets + }, getGroupClass (group) { const classes = [] // add any class set in the group's properties @@ -154,7 +176,7 @@ export default { classes.push(properties.class) } // dragging interaction classes - const dragDropClass = this.getDragDropClass(group) // Mixin method + const dragDropClass = this.getGroupDragDropClass(group) // Mixin method if (dragDropClass) { classes.push(dragDropClass) } @@ -179,7 +201,8 @@ export default { this.deployChanges({ dashboard: this.page.ui, page: this.page.id, - groups: this.pageGroups + groups: this.pageGroups, + widgets: this.pageGroupWidgets }).then(() => { this.acceptChanges() // Mixin method }).catch((error) => { @@ -197,7 +220,7 @@ export default { }, discardEdits () { this.revertEdits() // Mixin method - this.pageGroups = this.getPageGroups() + this.updateEditStateObjects() }, async leaveEditMode () { let leave = true @@ -218,6 +241,22 @@ export default { this.discardEdits() } this.exitEditMode() // Mixin method + }, + onGroupResize (opts) { + // ensure opts.width is a number and is greater than 0 + if (typeof opts.width !== 'number' || opts.width < 1) { + return + } + this.pageGroups[opts.index].width = opts.width + }, + updateEditStateObjects () { + console.log('Updating edit state objects') + this.pageGroups = this.getPageGroups() + const pageGroupWidgets = {} + for (const group of this.pageGroups) { + pageGroupWidgets[group.id] = this.getGroupWidgets(group.id) + } + this.pageGroupWidgets = pageGroupWidgets } } } diff --git a/ui/src/layouts/Group.vue b/ui/src/layouts/Group.vue index f9e147813..af0a7fa10 100644 --- a/ui/src/layouts/Group.vue +++ b/ui/src/layouts/Group.vue @@ -1,29 +1,72 @@