UNPKG

@flowfuse/nr-assistant

Version:
889 lines (823 loc) 40.6 kB
/** * FFAssistant Utils * Expert Communication functions for the FlowFuse Assistant * To import this in js backend code (although you shouldn't), use: * const FFExpertComms = require('flowfuse-nr-assistant/resources/expertComms.js') * To import this in frontend code, use: * <script src="/resources/@flowfuse/nr-assistant/expertComms.js"></script> * To use this in the browser, you can access it via: * FFExpertComms.cleanFlow(nodeArray) */ /* global $ */ 'use strict'; (function (root, factory) { if (typeof module === 'object' && module.exports) { // Node.js / CommonJS module.exports = factory() } else { // Browser root.FFExpertComms = factory() } }(typeof self !== 'undefined' ? self : this, function () { 'use strict' function debounce (func, wait) { let timeout return function () { const context = this; const args = arguments const later = function () { timeout = null func.apply(context, args) } clearTimeout(timeout) timeout = setTimeout(later, wait) } } // Copied from Node-RED core editor-client/src/js/ui/utils.js const loggingLevels = { off: 1, fatal: 10, error: 20, warn: 30, info: 40, debug: 50, trace: 60, audit: 98, metric: 99 } const logLevelLabels = { [loggingLevels.fatal]: 'fatal', [loggingLevels.error]: 'error', [loggingLevels.warn]: 'warn', [loggingLevels.info]: 'info', [loggingLevels.debug]: 'debug', [loggingLevels.trace]: 'trace', [loggingLevels.audit]: 'audit', [loggingLevels.metric]: 'metric' } const getNearestLoggingLevel = (level, fallback = logLevelLabels[loggingLevels.debug]) => { // if level == one of the strings, just return it const logLabels = Object.values(logLevelLabels) if (typeof level === 'string' && logLabels.includes(level)) { return level } if (loggingLevels[level]) { return loggingLevels[level] } const levelNo = +level if (isNaN(levelNo)) { return fallback } let nearestLevel = 0 Object.values(loggingLevels).forEach(l => { if (Math.abs(levelNo - l) < Math.abs(levelNo - nearestLevel)) { nearestLevel = l } }) return logLevelLabels[nearestLevel] || fallback } const hasProperty = (obj, prop) => !!(obj && Object.prototype.hasOwnProperty.call(obj, prop)) class ExpertComms { /** @type {import('node-red').NodeRedInstance} */ RED = null assistantOptions = {} expertSupportedFeatures = {} MESSAGE_SOURCE = 'nr-assistant' MESSAGE_TARGET = 'flowfuse-expert' MESSAGE_SCOPE = 'flowfuse-expert' /** * targetOrigin is set to '*' by default, which allows messages to be sent and received from any origin. * This is fine for the initial handshake with the FF Expert (will change to the origin of the expert page once it is loaded) * * @type {string} */ targetOrigin = '*' /** * Define supported actions and their parameter schemas */ supportedActions = { 'core:manage-palette': { params: { type: 'object', properties: { view: { type: 'string', enum: ['nodes', 'install'], default: 'install' }, filter: { description: 'Optional filter string. e.g. `"node-red-contrib-s7","node-red-contrib-other"` to pre-filter the palette view', type: 'string' } }, required: ['filter'] } }, 'custom:import-flow': { params: { type: 'object', properties: { flow: { type: 'string', description: 'The flow JSON to import' }, addFlow: { type: 'boolean', description: 'Whether to add the flow to the current workspace tab (false) or create a new tab (true). Default: false' } }, required: ['flow'] } }, 'custom:close-search': { params: null }, 'custom:close-typeSearch': { params: null }, 'custom:close-actionList': { params: null } } /** * A mapping of Node-RED core events to their respective handler logic. * * This map acts as a router for the assistant's event listeners: * - Functions: Executed immediately when the event fires (e.g., notifying the parent of UI state). * - Strings: Represent the name of a method within this class to be invoked (e.g., refreshing the palette). * The method name being referenced must be appended with 'notify' * * @type {Object.<string, Function|string>} */ nodeRedEventsMap = { // palette changes 'registry:node-set-added': 'notifyPaletteChange', 'registry:node-set-removed': 'notifyPaletteChange', 'registry:node-set-disabled': 'notifyPaletteChange', 'registry:node-set-enabled': 'notifyPaletteChange', // selection changes 'view:selection-changed': 'notifySelectionChanged' } /** * A mapping of FlowFuse Expert events to their respective handler logic. * * This map acts as a router for the expert's event listeners: * - Functions: Executed immediately when the event fires (e.g., notifying the parent of UI state). * - Strings: Represent the name of a method within this class to be invoked (e.g., refreshing the palette). * The method name being referenced must be appended with 'handle' * * @type {Object.<string, Function|string>} */ commandMap = { 'expert-ready': 'handleExpertReady', 'get-assistant-version': ({ event, type, action, params } = {}) => { // handle version request this.postReply({ type, version: this.assistantOptions.assistantVersion, success: true }, event) }, 'get-assistant-features': ({ event, type, action, params } = {}) => { // handle features request this.postReply({ type, features: this.features, success: true }, event) }, 'get-supported-actions': ({ event, type, action, params } = {}) => { // handle supported actions request this.postReply({ type, supportedActions: this.supportedActions, success: true }, event) }, 'get-palette': async ({ event, type, action, params } = {}) => { // handle palette request this.postReply({ type: 'set-palette', palette: await this.getPalette(), success: true }, event) }, 'invoke-action': 'handleActionInvocation', 'get-selection': 'handleGetSelection', 'register-event-listeners': 'handleRegisterEvents', 'debug-log-context-registered': 'handleDebugLogContextRegistration', 'debug-log-context-get-entries': 'handleDebugLogContextGetEntries' } /** * A set of flags and features supported by this plugin version. * These should be used by the FlowFuse Expert to determine what functionality can be leveraged. */ get features () { return { commands: Object.fromEntries(Object.entries(this.commandMap).map(([name, value]) => [name, { enabled: true }])), actions: Object.fromEntries(Object.entries(this.supportedActions).map(([name, value]) => [name, { enabled: true }])), registeredEvents: Object.fromEntries(Object.entries(this.nodeRedEventsMap).map(([name, value]) => [name, { enabled: true }])), // list of Node-RED events registered to be echoed to the expert dynamicEventRegistration: { enabled: true }, // supports dynamic registration of Node-RED events to be listened to flowSelection: { enabled: true }, // supports passing the flow selection flowImport: { enabled: true }, // supports importing flows paletteManagement: { enabled: true }, // supports palette management actions debugLogContext: { enabled: true } // supports providing debug log context to the expert } } init (RED, assistantOptions) { /** @type {import('node-red').NodeRedInstance} */ this.RED = RED this.assistantOptions = assistantOptions if (!window.parent?.postMessage || window.self === window.top) { console.warn('Parent window not detected - certain interactions with the FlowFuse Expert will not be available') return } this.setNodeRedEventListeners() this.setupMessageListeners() // Notify the parent window that the assistant is ready this.postParent({ type: 'assistant-ready', version: this.assistantOptions.assistantVersion, enabled: this.assistantOptions.enabled, standalone: this.assistantOptions.standalone, nodeRedVersion: this.RED.settings.version, nodeRedUpdatesAvailable: this.RED.palette?.editor?.getAvailableUpdates ? this.RED.palette.editor.getAvailableUpdates() : null, features: this.features }) // Hook into Node-RED's FrontEnd `debugPostProcessMessage` hook to add an "Add to context" button to debug log // entries in the debug sidebar, allowing users to easily add relevant debug messages to the FlowFuse Expert context. const allowHook = this.assistantOptions.enabled && this.assistantOptions.standalone !== true const hookValid = RED.hooks.isKnownHook ? RED.hooks.isKnownHook('debugPostProcessMessage') : false let alreadyHooked = false if (allowHook && hookValid && !alreadyHooked) { alreadyHooked = true const that = this // listen for `flows:loaded` (i.e. editor is ready) then hook the debug clear button const hookDebugClearButton = function () { if ($('#red-ui-sidebar-debug-clear').length) { $('#red-ui-sidebar-debug-clear').on('click', function () { that.postParent({ type: 'debug-log-context-clear', data: {} }) }) that.RED.events.off('flows:loaded', hookDebugClearButton) } } that.RED.events.on('flows:loaded', hookDebugClearButton) // Hook into the debug message post-processing to add our "Add to context" button RED.hooks.add('debugPostProcessMessage.nr-assistant', function (processedMessage) { try { const { message, element, payload } = processedMessage const metaRowTools = element && element.find('.red-ui-debug-msg-meta .red-ui-debug-msg-tools') if (!metaRowTools || !metaRowTools.length) { return // can't find the tools container, so we can't add our button } // Check if the button already exists to avoid adding multiple buttons if (metaRowTools.find('button.ff-expert-debug-context').length) { return // button already exists, no need to add another } // Create button and add data/click handler const buttonEl = $('<button class="ff-expert-debug-context red-ui-button red-ui-button-small"><i class="fa fa-plus"></i></button>') RED.popover.tooltip(buttonEl, 'Add to FlowFuse Expert context') buttonEl.css('cursor', 'pointer') // Here we generate a unique id here and store it as a data attribute on the button, along with the relevant data // (jquery data is used for storage as it allows storing complex objects, whereas data attributes can only store strings) const uuid = `${message._source?.id || message.id}:${Math.random().toString(16).slice(2)}${Date.now().toString(16)}` buttonEl.attr('data-ff-expert-debug-uuid', uuid) buttonEl.data('ff-expert-debug-data', { message, payload }) buttonEl.on('click', function (evt) { evt.preventDefault() evt.stopPropagation() // toggle off if already selected if (buttonEl.hasClass('selected')) { const uuid = buttonEl.attr('data-ff-expert-debug-uuid') that.postParent({ type: 'debug-log-context-remove', debugLog: [{ uuid }] }) return } // send formatted debug message to the parent const entry = that.formatDebugMessage(uuid, message, payload) that.postParent({ type: 'debug-log-context-add', debugLog: [entry] }) }) metaRowTools.append(buttonEl) } catch (_err) { // fallback to original function if any error is encountered this.debug('Error adding debug log context button, falling back to original element creation', _err) } }) } } setupMessageListeners () { // Listen for postMessages from the parent window window.addEventListener('message', async (event) => { // prevent own messages being processed if (event.source === window.self) { return } const { type, action, params, target, source, scope } = event.data || {} // Ensure scope and source match expected values if (target !== this.MESSAGE_SOURCE || source !== this.MESSAGE_TARGET || scope !== this.MESSAGE_SCOPE) { return } // Setting target origin for future calls if (this.targetOrigin === '*') { this.targetOrigin = event.origin } this.debug('Received postMessage:', event.data) const payload = { event, type, action, params } for (const eventName in this.commandMap) { if (type === eventName && typeof this.commandMap[eventName] === 'function') { return this.commandMap[eventName](payload) } if ( type === eventName && typeof this.commandMap[eventName] === 'string' && this.commandMap[eventName] in this ) { return this[this.commandMap[eventName]](payload) } } // handles unknown message type this.postReply({ type: 'error', error: 'unknown-type', data: event.data }, event) }, false) } /** * Register Node-RED events to be listened to and echoed to the FlowFuse Expert * @param {Record<string,string>} events - Key is Node-RED event to register, Value is event to emit back */ handleRegisterEvents ({ event, params }) { if (!params || typeof params !== 'object') { return } for (const key in params) { const eventMapping = params[key] // FF Expert will send { eventName: {nodeRedEvent: 'editor:open', future: xxx} } const callBackName = key // the key is the FF event name to call back on const nodeRedEvent = eventMapping.nodeRedEvent // the NR event to subscribe to if (callBackName && nodeRedEvent) { if (hasProperty(this.nodeRedEventsMap, nodeRedEvent)) { continue // nodeRedEvent already registered } this.nodeRedEventsMap[nodeRedEvent] = callBackName this.RED.events.on(nodeRedEvent, (eventData) => { this.postParent({ type: callBackName, eventMapping, eventData }) }) } } // send updated feature list this.postReply({ type: 'set-assistant-features', features: this.features }, event) } /** * This function syncs the selected debug log entries in the Node-RED debug sidebar with the FlowFuse Expert. * It should be called when the user clears logs from chat context */ handleDebugLogContextRegistration ({ event, params }) { if (!params || typeof params !== 'object') { return } const { register } = params if (register) { // update class on the debug panel // remove all .selected' $('button.ff-expert-debug-context').removeClass('selected') register.forEach(uuid => { // find the element with data-ff-expert-debug-uuid=uuid and add class .selected $(`button[data-ff-expert-debug-uuid="${uuid}"]`).addClass('selected') }) } } /** * This function retrieves the debug log entries from the Node-RED debug sidebar and sends them to the FlowFuse Expert. * It supports filtering by log level and visibility in the UI. * This is typically commanded by the user in the Chat UI where they may request to add debug log entries to the context. */ handleDebugLogContextGetEntries ({ event, params }) { const { visibleOnly = true, fatal = true, error = true, warn = true, info = true, debug = true, trace = true } = params || {} const wantLevels = [] if (fatal) wantLevels.push('fatal') if (error) wantLevels.push('error') if (warn) wantLevels.push('warn') if (info) wantLevels.push('info') if (debug) wantLevels.push('debug') if (trace) wantLevels.push('trace') const isElementInView = (jElement) => { if (jElement.length === 0) { return false } if (!visibleOnly) { return true } if (jElement.hasClass('hide')) { return false // not visible, skip this entry } const rect = jElement[0].getBoundingClientRect() if (!rect?.width || !rect?.height) { return false } return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= $(window).height() && rect.right <= $(window).width() ) } // first remove class .selected from all entries. It will be re-added once the expert responds with the list uuids it has registered. $('button.ff-expert-debug-context').removeClass('selected') const filteredEntries = [] // get buttons `#red-ui-sidebar-content .red-ui-debug-content-list button.ff-expert-debug-context` in the debug sidebar // but dont include any with `.hide` on the parent `.red-ui-debug-msg` element, as those are not visible $('#red-ui-sidebar-content .red-ui-debug-content-list button.ff-expert-debug-context').each((i, el) => { const expertToolButtonEl = $(el) const parent = expertToolButtonEl.closest('div.red-ui-debug-msg') if (!isElementInView(parent)) { return // hidden or not visible in the viewport, skip this entry } const uuid = expertToolButtonEl.attr('data-ff-expert-debug-uuid') if (uuid) { const data = expertToolButtonEl.data('ff-expert-debug-data') if (!data) return const { message, payload } = data const entry = this.formatDebugMessage(uuid, message, payload) const level = entry?.level if (!wantLevels.includes(level)) { return // not a level we want to include, skip this entry } filteredEntries.push(entry) } }) this.postReply({ type: 'debug-log-context-add', debugLog: filteredEntries }, event) } setNodeRedEventListeners () { Object.keys(this.nodeRedEventsMap).forEach(eventName => { if (typeof this.nodeRedEventsMap[eventName] === 'function') { this.RED.events.on(eventName, this.nodeRedEventsMap[eventName].bind(this)) } if (typeof this.nodeRedEventsMap[eventName] === 'string' && this.nodeRedEventsMap[eventName] in this) { this.RED.events.on(eventName, this[this.nodeRedEventsMap[eventName]].bind(this)) } }) } /** * FlowFuse Expert Node-RED event notifiers */ async notifyPaletteChange () { this.postParent({ type: 'set-palette', palette: await this.getPalette() }, true) // debounced } notifySelectionChanged ({ nodes }) { if (nodes && Array.isArray(nodes)) { this.postParent({ type: 'set-selection', selection: this.formatSelectedNodes(nodes) }) } else { this.postParent({ type: 'set-selection', selection: [] }) } } /** * FlowFuse Expert message handlers */ handleActionInvocation ({ event, type, action, params } = {}) { // handle action invocation requests (must be registered actions in supportedActions) if (typeof action !== 'string') { return } if (!this.supportedActions[action]) { console.warn(`Action "${action}" is not permitted to be invoked via postMessage`) this.postReply({ type, action, error: 'unknown-action' }, event) return } // Validate params against permitted schema (native/naive parsing for now - may introduce a library later if more complex schemas are needed) const actionSchema = this.supportedActions[action].params if (actionSchema) { const validation = this.validateSchema(params, actionSchema) if (!validation || !validation.valid) { console.warn(`Params for action "${action}" did not validate against the expected schema`, params, actionSchema, validation) this.postReply({ type, action, error: validation.error || 'invalid-parameters' }, event) return } } switch (action) { case 'custom:close-search': this.RED.search.hide() this.postReply({ type, action, acknowledged: true }, event) return case 'custom:close-typeSearch': this.RED.typeSearch.hide() this.postReply({ type, action, acknowledged: true }, event) return case 'custom:close-actionList': this.RED.actionList.hide() this.postReply({ type, action, acknowledged: true }, event) return case 'custom:import-flow': // import-flow is a custom action - handle it here directly try { this.importNodes(params.flow, params.addFlow === true) this.postReply({ type, success: true }, event) } catch (err) { this.postReply({ type, error: err?.message }, event) } return default: // Handle (supported) native Node-RED actions try { this.RED.actions.invoke(action, params) this.postReply({ type, action, success: true }, event) } catch (err) { this.postReply({ type, action, error: err?.message }, event) } } } handleGetSelection ({ event }) { let selection = [] const selectedNodes = this.RED.view.selection() if (selectedNodes && Array.isArray(selectedNodes)) { selection = this.formatSelectedNodes(this.RED.view.selection()) } this.postReply({ type: 'set-selection', selection }, event) } async getPalette () { const palette = {} const plugins = await $.ajax({ url: 'plugins', method: 'GET', headers: { Accept: 'application/json' } }) const nodes = await $.ajax({ url: 'nodes', method: 'GET', headers: { Accept: 'application/json' } }) plugins.forEach(plugin => { if (hasProperty(palette, plugin.module)) { palette[plugin.module].plugins.push(plugin) } else { palette[plugin.module] = { version: plugin.version, enabled: plugin.enabled, module: plugin.module, plugins: [ plugin ], nodes: [] } } }) nodes.forEach(node => { if (hasProperty(palette, node.module)) { palette[node.module].nodes.push(node) } else { palette[node.module] = { version: node.version, enabled: node.enabled, module: node.module, plugins: [], nodes: [ node ] } } }) return palette } handleExpertReady ({ event, params }) { // Expert is ready. `params` should contain flags for features the expert supports, which // can be used to conditionally enable/disable functionality in the assistant. // For now, the only functionality we need to gate is showing the "Add to FF Expert context" buttons // in the debug sidebar, which we don't want to show if the expert does not support debug log context! const { supportedFeatures } = params || {} this.expertSupportedFeatures = { ...supportedFeatures } // if the expert supports debug log context, then we should allow the buttons to be shown // by setting --ff-feature--display-debug-log-context: unset in the CSS if (supportedFeatures.debugLogContext && supportedFeatures.debugLogContext.enabled) { document.documentElement.style.setProperty('--ff-feature--display-debug-log-context', 'unset') } } formatSelectedNodes (nodes) { return this.RED.nodes.createExportableNodeSet(nodes, { includeModuleConfig: true }) } validateSchema (data, schema) { if (schema.type === 'object') { if (typeof data !== 'object') { return { valid: false, error: 'Data is not of type object' } } if (Array.isArray(data)) { return { valid: false, error: 'Data is an array but an object was expected' } } // check required properties if (Array.isArray(schema.required)) { for (const reqProp of schema.required) { if (!(reqProp in data)) { return { valid: false, error: `Data is missing required parameter "${reqProp}"` } } } } // check properties & apply defaults if (schema.properties) { for (const [propName, propSchema] of Object.entries(schema.properties)) { const propExists = propName in data // check type if (propSchema.type && propExists) { const expectedType = propSchema.type const actualType = Array.isArray(data[propName]) ? 'array' : typeof data[propName] if (actualType !== expectedType) { return { valid: false, error: `Data parameter "${propName}" is of type "${actualType}" but expected type is "${expectedType}"` } } } // check enum if (propSchema.enum && propExists) { if (!propSchema.enum.includes(data[propName])) { return { valid: false, error: `Data parameter "${propName}" has invalid value "${data[propName]}". Should be one of: ${propSchema.enum.join(', ')}` } } } // apply defaults if (propSchema.default !== undefined && !propExists) { data[propName] = propSchema.default } } } } return { valid: true } } /// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js` /** * Performs the import of nodes, handling any conflicts that may arise * @param {string} nodesStr the nodes to import as a string * @param {boolean} addFlow whether to add the nodes to a new flow or to the current flow */ importNodes (nodesStr, addFlow) { let newNodes = nodesStr if (typeof nodesStr === 'string') { try { nodesStr = nodesStr.trim() if (nodesStr.length === 0) { return } newNodes = this.validateFlowString(nodesStr) } catch (err) { const e = new Error(this.RED._('clipboard.invalidFlow', { message: 'test' })) e.code = 'NODE_RED' throw e } } const importOptions = { generateIds: true, addFlow } try { this.RED.view.importNodes(newNodes, importOptions) } catch (error) { // Thrown for import_conflict this.RED.notify('Import failed:' + error.message, 'error') throw error } } /// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js` /** * Validates if the provided string looks like valid flow json * @param {string} flowString the string to validate * @returns If valid, returns the node array */ validateFlowString (flowString) { const res = JSON.parse(flowString) if (!Array.isArray(res)) { throw new Error(this.RED._('clipboard.import.errors.notArray')) } for (let i = 0; i < res.length; i++) { if (typeof res[i] !== 'object') { throw new Error(this.RED._('clipboard.import.errors.itemNotObject', { index: i })) } if (!Object.hasOwn(res[i], 'id')) { throw new Error(this.RED._('clipboard.import.errors.missingId', { index: i })) } if (!Object.hasOwn(res[i], 'type')) { throw new Error(this.RED._('clipboard.import.errors.missingType', { index: i })) } } return res } debug (...args) { if (this.RED.nrAssistant?.DEBUG) { const scriptName = 'assistant-index.html.js' // must match the sourceURL set in the script below const stackLine = new Error().stack.split('\n')[2].trim() const match = stackLine.match(/\(?([^\s)]+):(\d+):(\d+)\)?$/) || stackLine.match(/@?([^@]+):(\d+):(\d+)$/) const file = match?.[1] || 'anonymous' const line = match?.[2] || '1' const col = match?.[3] || '1' let link = `${window.location.origin}/${scriptName}:${line}:${col}` if (/^VM\d+$/.test(file)) { link = `debugger:///${file}:${line}:${col}` } else if (file !== 'anonymous' && file !== '<anonymous>' && file !== scriptName) { link = `${file}:${line}:${col}` if (!link.startsWith('http') && !link.includes('/')) { link = `${window.location.origin}/${link}` } } // eslint-disable-next-line no-console console.log('[nr-assistant]', ...args, `\n at ${link}`) } } /** * Internal helper to send a formatted message to a target window */ _post (payload, targetWindow) { if (targetWindow && typeof targetWindow.postMessage === 'function') { targetWindow.postMessage({ ...payload, source: this.MESSAGE_SOURCE, scope: this.MESSAGE_SCOPE, target: this.MESSAGE_TARGET }, this.targetOrigin) } else { console.warn('Unable to post message, target window not available', payload) } } _postDebounced = debounce(this._post, 150) postParent (payload = {}, debounce) { if (debounce) { this.debug('Posting parent message (debounced)', payload) this._postDebounced(payload, window.parent) } else { this.debug('Posting parent message', payload) this._post(payload, window.parent) } } postReply (payload, event) { this.debug('Posting reply message:', payload) this._post(payload, event.source) } /** * Internal helper to format a debug log entry based on the options and message provided by the Node-RED debug sidebar, * enriched with metadata about the source node and workspace. * @param {string} uuid - a unique identifier for this debug log entry, used to manage selection state between Node-RED and the FlowFuse Expert * @param {*} message - the debug message object * @param {*} payload - the debug payload as sent from backend (rehydrated) * @returns an object representing the debug log entry, enriched with metadata about its source and context, suitable for sending to the FlowFuse Expert */ formatDebugMessage (uuid, message, payload) { const getInfo = (id, name, type, z) => { const result = { id, name: name || '', type, z } const node = this.RED.nodes.node(id) const isNodeSubFlowInstance = (node?.type || '').startsWith('subflow:') const flow = !node ? this.RED.nodes.workspace(id) : null if (node) { result.type = node.type || result.type result.name = node.name || result.name result.z = node.z || result.z if (isNodeSubFlowInstance) { result.isSubflowInstance = true result.subflowTemplateId = node.type.replace('subflow:', '') const subflowTemplate = this.RED.nodes.subflow(result.subflowTemplateId) result.subFlowTemplateName = subflowTemplate?.name || '' } if (node._def?.category === 'config') { result.isConfig = true } } else if (flow) { result.type = flow.type || result.type || 'tab' result.name = flow.name || result.name if (!flow.z && !result.z) { result.z = flow.id // set to self (to aid in identification in the expert) } } return result } const _source = message._source || {} const _sourceId = message._alias || _source._alias || _source.id || message.id || '' const _hierarchy = _source.pathHierarchy || [{ id: _sourceId }] const hierarchy = _hierarchy.map(e => getInfo(e.id, e.name || e.label, e.type, e.z)) const source = hierarchy.pop() const ancestors = hierarchy const metadata = { format: message.format, timestamp: message.timestamp || Date.now(), path: message.path || '' } if (hasProperty(message, 'topic')) { metadata.topic = message.topic // sometimes the message has a topic } if (hasProperty(message, 'property')) { metadata.property = message.property // if the message has a property, it indicates what property of the message is being debugged (e.g. msg.payload, msg.payload.value, etc.) } const event = { uuid, level: getNearestLoggingLevel(message.level, 'debug'), // default to 'debug' if no level is provided data: payload, // the data in the debug message, as sent from the backend (rehydrated) source, // info about the node that generated the debug message ancestors, // info about the parent nodes of the source node, up to the workspace level metadata } return event } } return new ExpertComms() }))