node-red-dashboard-2-hmi 0.1.10 → 0.1.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +144 -140
- package/LICENSE +21 -21
- package/README.md +273 -273
- package/docs/mensagens.md +266 -266
- package/examples/clp-schneider.json +431 -431
- package/examples/envase.json +545 -545
- package/examples/esteira.json +639 -639
- package/examples/pintura.json +610 -610
- package/examples/tela-basica.json +572 -572
- package/nodes/access.js +69 -69
- package/nodes/admin.js +170 -166
- package/nodes/alarms.js +177 -177
- package/nodes/clients.js +31 -31
- package/nodes/plugin.js +24 -24
- package/nodes/trend.js +54 -54
- package/nodes/ui-hmi-canvas.html +383 -383
- package/nodes/ui-hmi-canvas.js +335 -335
- package/nodes/values.js +176 -176
- package/package.json +63 -63
- package/resources/hmi-editor.js +10893 -10887
- package/resources/ui-hmi-canvas.umd.js +667 -667
package/nodes/access.js
CHANGED
|
@@ -1,69 +1,69 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
// Who may do what on a canvas. Each list is typed in the node:
|
|
4
|
-
// empty -> everybody
|
|
5
|
-
// "-" -> nobody (view only)
|
|
6
|
-
// names -> only these users, separated by commas (user name, login or e-mail)
|
|
7
|
-
// The user comes from the dashboard login (FlowFuse / multi-user plugins or basic auth),
|
|
8
|
-
// so these checks run in Node-RED and a page cannot get around them.
|
|
9
|
-
const ROLES = ['cmd', 'ack', 'shelve']
|
|
10
|
-
|
|
11
|
-
function parseList (text) {
|
|
12
|
-
const t = String(text || '').trim()
|
|
13
|
-
if (!t) return null
|
|
14
|
-
if (t === '-') return []
|
|
15
|
-
return t.split(/[,;\n]/).map(s => s.trim().toLowerCase()).filter(Boolean)
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function namesOf (user) {
|
|
19
|
-
if (!user) return []
|
|
20
|
-
if (typeof user === 'string') return [user.toLowerCase()]
|
|
21
|
-
return ['name', 'username', 'email', 'userId', 'id', 'login']
|
|
22
|
-
.map(k => user[k]).filter(v => typeof v === 'string' && v).map(v => v.toLowerCase())
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
function createAccess (config) {
|
|
26
|
-
const lists = {
|
|
27
|
-
cmd: parseList(config.cmdUsers),
|
|
28
|
-
ack: parseList(config.ackUsers),
|
|
29
|
-
// shelving needs the same people as acknowledging when it has no list of its own
|
|
30
|
-
shelve: parseList(config.shelveUsers !== undefined && String(config.shelveUsers).trim() !== '' ? config.shelveUsers : config.ackUsers)
|
|
31
|
-
}
|
|
32
|
-
const can = (role, client) => {
|
|
33
|
-
const list = lists[role]
|
|
34
|
-
if (list === null || list === undefined) return true
|
|
35
|
-
const names = namesOf(client && client._client ? client._client.user : client && client.user)
|
|
36
|
-
return names.some(n => list.includes(n))
|
|
37
|
-
}
|
|
38
|
-
return {
|
|
39
|
-
can,
|
|
40
|
-
// what a page may show as enabled for this client
|
|
41
|
-
of (client) {
|
|
42
|
-
const out = {}
|
|
43
|
-
for (const r of ROLES) out[r] = can(r, client)
|
|
44
|
-
return out
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Wrong passwords: after 5 tries in 5 minutes the address waits 1 minute.
|
|
50
|
-
function createPinGuard ({ tries = 5, windowMs = 5 * 60000, lockMs = 60000 } = {}) {
|
|
51
|
-
const fails = new Map()
|
|
52
|
-
return {
|
|
53
|
-
locked (who, now) {
|
|
54
|
-
const f = fails.get(who)
|
|
55
|
-
return !!(f && f.until && f.until > now)
|
|
56
|
-
},
|
|
57
|
-
fail (who, now) {
|
|
58
|
-
const f = fails.get(who) || { list: [], until: 0 }
|
|
59
|
-
f.list = f.list.filter(t => t > now - windowMs)
|
|
60
|
-
f.list.push(now)
|
|
61
|
-
if (f.list.length >= tries) { f.until = now + lockMs; f.list = [] }
|
|
62
|
-
fails.set(who, f)
|
|
63
|
-
return f.until > now
|
|
64
|
-
},
|
|
65
|
-
ok (who) { fails.delete(who) }
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
module.exports = { createAccess, createPinGuard, parseList }
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
// Who may do what on a canvas. Each list is typed in the node:
|
|
4
|
+
// empty -> everybody
|
|
5
|
+
// "-" -> nobody (view only)
|
|
6
|
+
// names -> only these users, separated by commas (user name, login or e-mail)
|
|
7
|
+
// The user comes from the dashboard login (FlowFuse / multi-user plugins or basic auth),
|
|
8
|
+
// so these checks run in Node-RED and a page cannot get around them.
|
|
9
|
+
const ROLES = ['cmd', 'ack', 'shelve']
|
|
10
|
+
|
|
11
|
+
function parseList (text) {
|
|
12
|
+
const t = String(text || '').trim()
|
|
13
|
+
if (!t) return null
|
|
14
|
+
if (t === '-') return []
|
|
15
|
+
return t.split(/[,;\n]/).map(s => s.trim().toLowerCase()).filter(Boolean)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function namesOf (user) {
|
|
19
|
+
if (!user) return []
|
|
20
|
+
if (typeof user === 'string') return [user.toLowerCase()]
|
|
21
|
+
return ['name', 'username', 'email', 'userId', 'id', 'login']
|
|
22
|
+
.map(k => user[k]).filter(v => typeof v === 'string' && v).map(v => v.toLowerCase())
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function createAccess (config) {
|
|
26
|
+
const lists = {
|
|
27
|
+
cmd: parseList(config.cmdUsers),
|
|
28
|
+
ack: parseList(config.ackUsers),
|
|
29
|
+
// shelving needs the same people as acknowledging when it has no list of its own
|
|
30
|
+
shelve: parseList(config.shelveUsers !== undefined && String(config.shelveUsers).trim() !== '' ? config.shelveUsers : config.ackUsers)
|
|
31
|
+
}
|
|
32
|
+
const can = (role, client) => {
|
|
33
|
+
const list = lists[role]
|
|
34
|
+
if (list === null || list === undefined) return true
|
|
35
|
+
const names = namesOf(client && client._client ? client._client.user : client && client.user)
|
|
36
|
+
return names.some(n => list.includes(n))
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
can,
|
|
40
|
+
// what a page may show as enabled for this client
|
|
41
|
+
of (client) {
|
|
42
|
+
const out = {}
|
|
43
|
+
for (const r of ROLES) out[r] = can(r, client)
|
|
44
|
+
return out
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Wrong passwords: after 5 tries in 5 minutes the address waits 1 minute.
|
|
50
|
+
function createPinGuard ({ tries = 5, windowMs = 5 * 60000, lockMs = 60000 } = {}) {
|
|
51
|
+
const fails = new Map()
|
|
52
|
+
return {
|
|
53
|
+
locked (who, now) {
|
|
54
|
+
const f = fails.get(who)
|
|
55
|
+
return !!(f && f.until && f.until > now)
|
|
56
|
+
},
|
|
57
|
+
fail (who, now) {
|
|
58
|
+
const f = fails.get(who) || { list: [], until: 0 }
|
|
59
|
+
f.list = f.list.filter(t => t > now - windowMs)
|
|
60
|
+
f.list.push(now)
|
|
61
|
+
if (f.list.length >= tries) { f.until = now + lockMs; f.list = [] }
|
|
62
|
+
fails.set(who, f)
|
|
63
|
+
return f.until > now
|
|
64
|
+
},
|
|
65
|
+
ok (who) { fails.delete(who) }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
module.exports = { createAccess, createPinGuard, parseList }
|
package/nodes/admin.js
CHANGED
|
@@ -1,166 +1,170 @@
|
|
|
1
|
-
'use strict'
|
|
2
|
-
|
|
3
|
-
const crypto = require('crypto')
|
|
4
|
-
const http = require('http')
|
|
5
|
-
const https = require('https')
|
|
6
|
-
const net = require('net')
|
|
7
|
-
|
|
8
|
-
// Edit sessions opened from the Node-RED editor: canvas id -> { key, until }
|
|
9
|
-
const editKeys = new Map()
|
|
10
|
-
const EDIT_TTL = 4 * 3600 * 1000
|
|
11
|
-
|
|
12
|
-
const RULE_OPS = ['on', 'off', 'eq', 'ne', 'gt', 'lt']
|
|
13
|
-
const RULE_EFFECTS = ['alarm', 'warn', 'info', 'ok', 'dim', 'blink', 'hide']
|
|
14
|
-
const ACTIONS = ['value', 'toggle', 'pulse', 'input', 'page', 'screen', 'faceplate']
|
|
15
|
-
const FACEPLATE_KEYS = ['modeKey', 'modeTexts', 'hoursKey', 'startsKey', 'valueKey', 'valueUnit', 'onTopic', 'onValue', 'offTopic', 'offValue', 'resetTopic', 'resetValue',
|
|
16
|
-
// PID / regulator: process value, setpoint, output and auto/manual
|
|
17
|
-
'pvKey', 'spKey', 'opKey', 'pidUnit', 'spTopic', 'spMin', 'spMax', 'opTopic', 'opMin', 'opMax', 'modeTopic', 'autoValue', 'manValue']
|
|
18
|
-
|
|
19
|
-
// screen names of a canvas: unique strings, at most
|
|
20
|
-
function cleanScreens (list) {
|
|
21
|
-
const out = []
|
|
22
|
-
for (const n of Array.isArray(list) ? list : []) {
|
|
23
|
-
const t = typeof n === 'string' ? n.trim().slice(0, 40) : ''
|
|
24
|
-
if (t && !out.includes(t) && out.length <
|
|
25
|
-
}
|
|
26
|
-
return out
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
function cleanSymbols (list) {
|
|
30
|
-
if (!Array.isArray(list)) return null
|
|
31
|
-
const out = []
|
|
32
|
-
for (const s of list) {
|
|
33
|
-
if (!s || typeof s !== 'object' || typeof s.id !== 'string' || typeof s.type !== 'string') return null
|
|
34
|
-
const n = v => (isFinite(Number(v)) ? Number(v) : 0)
|
|
35
|
-
const clean = {
|
|
36
|
-
id: s.id.slice(0, 64),
|
|
37
|
-
type: s.type.slice(0, 32),
|
|
38
|
-
x: n(s.x),
|
|
39
|
-
y: n(s.y),
|
|
40
|
-
w: Math.max(10, n(s.w)),
|
|
41
|
-
h: Math.max(10, n(s.h)),
|
|
42
|
-
rot: n(s.rot),
|
|
43
|
-
key: typeof s.key === 'string' ? s.key : '',
|
|
44
|
-
props: s.props && typeof s.props === 'object' && !Array.isArray(s.props) ? s.props : {}
|
|
45
|
-
}
|
|
46
|
-
if (s.preview !== undefined && ['number', 'string', 'boolean'].includes(typeof s.preview)) clean.preview = s.preview
|
|
47
|
-
const a = s.action
|
|
48
|
-
if (typeof s.group === 'string' && s.group) clean.group = s.group.slice(0, 64)
|
|
49
|
-
if (typeof s.name === 'string' && s.name) clean.name = s.name.slice(0, 80)
|
|
50
|
-
if (typeof s.screen === 'string' && s.screen) clean.screen = s.screen.slice(0, 40)
|
|
51
|
-
if (s.locked === true) clean.locked = true
|
|
52
|
-
const sc = s.scale
|
|
53
|
-
if (sc && typeof sc === 'object') clean.scale = { factor: String(sc.factor ?? ''), divisor: String(sc.divisor ?? ''), offset: String(sc.offset ?? '') }
|
|
54
|
-
if (Array.isArray(s.rules)) {
|
|
55
|
-
clean.rules = s.rules.slice(0, 20).filter(r => r && typeof r === 'object').map(r => ({
|
|
56
|
-
key: String(r.key ?? '').slice(0, 200),
|
|
57
|
-
op: RULE_OPS.includes(r.op) ? r.op : 'on',
|
|
58
|
-
value: String(r.value ?? '').slice(0, 200),
|
|
59
|
-
effect: RULE_EFFECTS.includes(r.effect) ? r.effect : 'alarm'
|
|
60
|
-
}))
|
|
61
|
-
}
|
|
62
|
-
if (a && typeof a === 'object' && ACTIONS.includes(a.mode)) {
|
|
63
|
-
clean.action = { mode: a.mode, value: String(a.value ?? ''), topic: String(a.topic ?? ''), confirm: a.confirm === 'yes' ? 'yes' : 'no', pin: a.pin === 'yes' ? 'yes' : 'no' }
|
|
64
|
-
if (a.interlock) Object.assign(clean.action, { interlock: String(a.interlock).slice(0, 300), interlockText: String(a.interlockText ?? '').slice(0, 120) })
|
|
65
|
-
if (a.mode === 'input') Object.assign(clean.action, { min: String(a.min ?? ''), max: String(a.max ?? ''), maxStep: String(a.maxStep ?? '') })
|
|
66
|
-
if (a.mode === 'faceplate') for (const k of FACEPLATE_KEYS) clean.action[k] = String(a[k] ?? '').slice(0, 200)
|
|
67
|
-
}
|
|
68
|
-
out.push(clean)
|
|
69
|
-
}
|
|
70
|
-
return out
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Calls this Node-RED's own admin API (GET/POST /flows) with the caller's credentials.
|
|
74
|
-
async function adminRequest (RED, req, method, body) {
|
|
75
|
-
let tls = RED.settings.https
|
|
76
|
-
if (typeof tls === 'function') tls = await tls()
|
|
77
|
-
const lib = tls ? https : http
|
|
78
|
-
// always this machine: an address of its own (uiHost) or loopback, never a host
|
|
79
|
-
// name, so the user's credentials cannot be sent anywhere else
|
|
80
|
-
const h = RED.settings.uiHost
|
|
81
|
-
const host = (!h || h === '0.0.0.0' || h === '::' || !net.isIP(h)) ? '127.0.0.1' : h
|
|
82
|
-
const root = typeof RED.settings.httpAdminRoot === 'string' ? RED.settings.httpAdminRoot : '/'
|
|
83
|
-
const headers = { 'Node-RED-API-Version': 'v2', Accept: 'application/json' }
|
|
84
|
-
if (req.headers.authorization) headers.authorization = req.headers.authorization
|
|
85
|
-
if (req.headers.cookie) headers.cookie = req.headers.cookie
|
|
86
|
-
let data
|
|
87
|
-
if (body) {
|
|
88
|
-
data = Buffer.from(JSON.stringify(body))
|
|
89
|
-
headers['Content-Type'] = 'application/json'
|
|
90
|
-
headers['Content-Length'] = data.length
|
|
91
|
-
headers['Node-RED-Deployment-Type'] = 'nodes'
|
|
92
|
-
}
|
|
93
|
-
return new Promise((resolve, reject) => {
|
|
94
|
-
const r = lib.request({
|
|
95
|
-
host,
|
|
96
|
-
port: RED.settings.uiPort || 1880,
|
|
97
|
-
path: ('/' + root + '/flows').replace(/\/+/g, '/'),
|
|
98
|
-
method,
|
|
99
|
-
headers,
|
|
100
|
-
// our own address: its certificate is not issued for it
|
|
101
|
-
rejectUnauthorized: false,
|
|
102
|
-
...(tls && typeof tls === 'object' ? { ca: tls.ca, cert: tls.cert, key: tls.key } : {})
|
|
103
|
-
}, res => {
|
|
104
|
-
let text = ''
|
|
105
|
-
res.on('data', c => { text += c })
|
|
106
|
-
res.on('end', () => {
|
|
107
|
-
let json = null
|
|
108
|
-
try { json = text ? JSON.parse(text) : null } catch (e) {}
|
|
109
|
-
resolve({ status: res.statusCode, json, text })
|
|
110
|
-
})
|
|
111
|
-
})
|
|
112
|
-
r.on('error', reject)
|
|
113
|
-
if (data) r.write(data)
|
|
114
|
-
r.end()
|
|
115
|
-
})
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
module.exports = function setupAdmin (RED) {
|
|
119
|
-
const canWrite = RED.auth.needsPermission('flows.write')
|
|
120
|
-
|
|
121
|
-
// Start editing a deployed canvas from the dashboard page
|
|
122
|
-
RED.httpAdmin.patch('/hmi-canvas/:id/edit', canWrite, function (req, res) {
|
|
123
|
-
const node = RED.nodes.getNode(req.params.id)
|
|
124
|
-
if (!node || node.type !== 'ui-hmi-canvas') {
|
|
125
|
-
return res.status(404).json({ error: 'Canvas not deployed', code: 'NOT_DEPLOYED' })
|
|
126
|
-
}
|
|
127
|
-
const key = crypto.randomBytes(16).toString('hex')
|
|
128
|
-
editKeys.set(req.params.id, { key, until: Date.now() + EDIT_TTL })
|
|
129
|
-
res.json({ key })
|
|
130
|
-
})
|
|
131
|
-
|
|
132
|
-
// Save the symbols edited on the dashboard page and deploy only this node
|
|
133
|
-
RED.httpAdmin.post('/hmi-canvas/:id/symbols', canWrite, async function (req, res) {
|
|
134
|
-
const id = req.params.id
|
|
135
|
-
const body = req.body || {}
|
|
136
|
-
const open = editKeys.get(id)
|
|
137
|
-
if (open && open.until < Date.now()) editKeys.delete(id)
|
|
138
|
-
if (!body.key || !open || open.key !== body.key || open.until < Date.now()) {
|
|
139
|
-
return res.status(403).json({ error: 'Edit session expired, open the editor again from Node-RED', code: 'BAD_KEY' })
|
|
140
|
-
}
|
|
141
|
-
const symbols = cleanSymbols(body.symbols)
|
|
142
|
-
if (!symbols) return res.status(400).json({ error: 'Invalid symbols', code: 'BAD_SYMBOLS' })
|
|
143
|
-
try {
|
|
144
|
-
const current = await adminRequest(RED, req, 'GET')
|
|
145
|
-
if (current.status !== 200 || !current.json) {
|
|
146
|
-
return res.status(current.status || 500).json({ error: 'Could not read flows', code: 'GET_FAILED' })
|
|
147
|
-
}
|
|
148
|
-
const flows = current.json.flows
|
|
149
|
-
const target = flows.find(n => n.id === id && n.type === 'ui-hmi-canvas')
|
|
150
|
-
if (!target) return res.status(404).json({ error: 'Canvas not found', code: 'NOT_FOUND' })
|
|
151
|
-
target.symbols = symbols
|
|
152
|
-
if (body.screens !== undefined)
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto')
|
|
4
|
+
const http = require('http')
|
|
5
|
+
const https = require('https')
|
|
6
|
+
const net = require('net')
|
|
7
|
+
|
|
8
|
+
// Edit sessions opened from the Node-RED editor: canvas id -> { key, until }
|
|
9
|
+
const editKeys = new Map()
|
|
10
|
+
const EDIT_TTL = 4 * 3600 * 1000
|
|
11
|
+
|
|
12
|
+
const RULE_OPS = ['on', 'off', 'eq', 'ne', 'gt', 'lt']
|
|
13
|
+
const RULE_EFFECTS = ['alarm', 'warn', 'info', 'ok', 'dim', 'blink', 'hide']
|
|
14
|
+
const ACTIONS = ['value', 'toggle', 'pulse', 'input', 'page', 'screen', 'faceplate']
|
|
15
|
+
const FACEPLATE_KEYS = ['modeKey', 'modeTexts', 'hoursKey', 'startsKey', 'valueKey', 'valueUnit', 'onTopic', 'onValue', 'offTopic', 'offValue', 'resetTopic', 'resetValue',
|
|
16
|
+
// PID / regulator: process value, setpoint, output and auto/manual
|
|
17
|
+
'pvKey', 'spKey', 'opKey', 'pidUnit', 'spTopic', 'spMin', 'spMax', 'opTopic', 'opMin', 'opMax', 'modeTopic', 'autoValue', 'manValue']
|
|
18
|
+
|
|
19
|
+
// screen names of a canvas: unique strings, at most 100
|
|
20
|
+
function cleanScreens (list) {
|
|
21
|
+
const out = []
|
|
22
|
+
for (const n of Array.isArray(list) ? list : []) {
|
|
23
|
+
const t = typeof n === 'string' ? n.trim().slice(0, 40) : ''
|
|
24
|
+
if (t && !out.includes(t) && out.length < 100) out.push(t)
|
|
25
|
+
}
|
|
26
|
+
return out
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function cleanSymbols (list) {
|
|
30
|
+
if (!Array.isArray(list)) return null
|
|
31
|
+
const out = []
|
|
32
|
+
for (const s of list) {
|
|
33
|
+
if (!s || typeof s !== 'object' || typeof s.id !== 'string' || typeof s.type !== 'string') return null
|
|
34
|
+
const n = v => (isFinite(Number(v)) ? Number(v) : 0)
|
|
35
|
+
const clean = {
|
|
36
|
+
id: s.id.slice(0, 64),
|
|
37
|
+
type: s.type.slice(0, 32),
|
|
38
|
+
x: n(s.x),
|
|
39
|
+
y: n(s.y),
|
|
40
|
+
w: Math.max(10, n(s.w)),
|
|
41
|
+
h: Math.max(10, n(s.h)),
|
|
42
|
+
rot: n(s.rot),
|
|
43
|
+
key: typeof s.key === 'string' ? s.key : '',
|
|
44
|
+
props: s.props && typeof s.props === 'object' && !Array.isArray(s.props) ? s.props : {}
|
|
45
|
+
}
|
|
46
|
+
if (s.preview !== undefined && ['number', 'string', 'boolean'].includes(typeof s.preview)) clean.preview = s.preview
|
|
47
|
+
const a = s.action
|
|
48
|
+
if (typeof s.group === 'string' && s.group) clean.group = s.group.slice(0, 64)
|
|
49
|
+
if (typeof s.name === 'string' && s.name) clean.name = s.name.slice(0, 80)
|
|
50
|
+
if (typeof s.screen === 'string' && s.screen) clean.screen = s.screen.slice(0, 40)
|
|
51
|
+
if (s.locked === true) clean.locked = true
|
|
52
|
+
const sc = s.scale
|
|
53
|
+
if (sc && typeof sc === 'object') clean.scale = { factor: String(sc.factor ?? ''), divisor: String(sc.divisor ?? ''), offset: String(sc.offset ?? '') }
|
|
54
|
+
if (Array.isArray(s.rules)) {
|
|
55
|
+
clean.rules = s.rules.slice(0, 20).filter(r => r && typeof r === 'object').map(r => ({
|
|
56
|
+
key: String(r.key ?? '').slice(0, 200),
|
|
57
|
+
op: RULE_OPS.includes(r.op) ? r.op : 'on',
|
|
58
|
+
value: String(r.value ?? '').slice(0, 200),
|
|
59
|
+
effect: RULE_EFFECTS.includes(r.effect) ? r.effect : 'alarm'
|
|
60
|
+
}))
|
|
61
|
+
}
|
|
62
|
+
if (a && typeof a === 'object' && ACTIONS.includes(a.mode)) {
|
|
63
|
+
clean.action = { mode: a.mode, value: String(a.value ?? ''), topic: String(a.topic ?? ''), confirm: a.confirm === 'yes' ? 'yes' : 'no', pin: a.pin === 'yes' ? 'yes' : 'no' }
|
|
64
|
+
if (a.interlock) Object.assign(clean.action, { interlock: String(a.interlock).slice(0, 300), interlockText: String(a.interlockText ?? '').slice(0, 120) })
|
|
65
|
+
if (a.mode === 'input') Object.assign(clean.action, { min: String(a.min ?? ''), max: String(a.max ?? ''), maxStep: String(a.maxStep ?? '') })
|
|
66
|
+
if (a.mode === 'faceplate') for (const k of FACEPLATE_KEYS) clean.action[k] = String(a[k] ?? '').slice(0, 200)
|
|
67
|
+
}
|
|
68
|
+
out.push(clean)
|
|
69
|
+
}
|
|
70
|
+
return out
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Calls this Node-RED's own admin API (GET/POST /flows) with the caller's credentials.
|
|
74
|
+
async function adminRequest (RED, req, method, body) {
|
|
75
|
+
let tls = RED.settings.https
|
|
76
|
+
if (typeof tls === 'function') tls = await tls()
|
|
77
|
+
const lib = tls ? https : http
|
|
78
|
+
// always this machine: an address of its own (uiHost) or loopback, never a host
|
|
79
|
+
// name, so the user's credentials cannot be sent anywhere else
|
|
80
|
+
const h = RED.settings.uiHost
|
|
81
|
+
const host = (!h || h === '0.0.0.0' || h === '::' || !net.isIP(h)) ? '127.0.0.1' : h
|
|
82
|
+
const root = typeof RED.settings.httpAdminRoot === 'string' ? RED.settings.httpAdminRoot : '/'
|
|
83
|
+
const headers = { 'Node-RED-API-Version': 'v2', Accept: 'application/json' }
|
|
84
|
+
if (req.headers.authorization) headers.authorization = req.headers.authorization
|
|
85
|
+
if (req.headers.cookie) headers.cookie = req.headers.cookie
|
|
86
|
+
let data
|
|
87
|
+
if (body) {
|
|
88
|
+
data = Buffer.from(JSON.stringify(body))
|
|
89
|
+
headers['Content-Type'] = 'application/json'
|
|
90
|
+
headers['Content-Length'] = data.length
|
|
91
|
+
headers['Node-RED-Deployment-Type'] = 'nodes'
|
|
92
|
+
}
|
|
93
|
+
return new Promise((resolve, reject) => {
|
|
94
|
+
const r = lib.request({
|
|
95
|
+
host,
|
|
96
|
+
port: RED.settings.uiPort || 1880,
|
|
97
|
+
path: ('/' + root + '/flows').replace(/\/+/g, '/'),
|
|
98
|
+
method,
|
|
99
|
+
headers,
|
|
100
|
+
// our own address: its certificate is not issued for it
|
|
101
|
+
rejectUnauthorized: false,
|
|
102
|
+
...(tls && typeof tls === 'object' ? { ca: tls.ca, cert: tls.cert, key: tls.key } : {})
|
|
103
|
+
}, res => {
|
|
104
|
+
let text = ''
|
|
105
|
+
res.on('data', c => { text += c })
|
|
106
|
+
res.on('end', () => {
|
|
107
|
+
let json = null
|
|
108
|
+
try { json = text ? JSON.parse(text) : null } catch (e) {}
|
|
109
|
+
resolve({ status: res.statusCode, json, text })
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
r.on('error', reject)
|
|
113
|
+
if (data) r.write(data)
|
|
114
|
+
r.end()
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = function setupAdmin (RED) {
|
|
119
|
+
const canWrite = RED.auth.needsPermission('flows.write')
|
|
120
|
+
|
|
121
|
+
// Start editing a deployed canvas from the dashboard page
|
|
122
|
+
RED.httpAdmin.patch('/hmi-canvas/:id/edit', canWrite, function (req, res) {
|
|
123
|
+
const node = RED.nodes.getNode(req.params.id)
|
|
124
|
+
if (!node || node.type !== 'ui-hmi-canvas') {
|
|
125
|
+
return res.status(404).json({ error: 'Canvas not deployed', code: 'NOT_DEPLOYED' })
|
|
126
|
+
}
|
|
127
|
+
const key = crypto.randomBytes(16).toString('hex')
|
|
128
|
+
editKeys.set(req.params.id, { key, until: Date.now() + EDIT_TTL })
|
|
129
|
+
res.json({ key })
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
// Save the symbols edited on the dashboard page and deploy only this node
|
|
133
|
+
RED.httpAdmin.post('/hmi-canvas/:id/symbols', canWrite, async function (req, res) {
|
|
134
|
+
const id = req.params.id
|
|
135
|
+
const body = req.body || {}
|
|
136
|
+
const open = editKeys.get(id)
|
|
137
|
+
if (open && open.until < Date.now()) editKeys.delete(id)
|
|
138
|
+
if (!body.key || !open || open.key !== body.key || open.until < Date.now()) {
|
|
139
|
+
return res.status(403).json({ error: 'Edit session expired, open the editor again from Node-RED', code: 'BAD_KEY' })
|
|
140
|
+
}
|
|
141
|
+
const symbols = cleanSymbols(body.symbols)
|
|
142
|
+
if (!symbols) return res.status(400).json({ error: 'Invalid symbols', code: 'BAD_SYMBOLS' })
|
|
143
|
+
try {
|
|
144
|
+
const current = await adminRequest(RED, req, 'GET')
|
|
145
|
+
if (current.status !== 200 || !current.json) {
|
|
146
|
+
return res.status(current.status || 500).json({ error: 'Could not read flows', code: 'GET_FAILED' })
|
|
147
|
+
}
|
|
148
|
+
const flows = current.json.flows
|
|
149
|
+
const target = flows.find(n => n.id === id && n.type === 'ui-hmi-canvas')
|
|
150
|
+
if (!target) return res.status(404).json({ error: 'Canvas not found', code: 'NOT_FOUND' })
|
|
151
|
+
target.symbols = symbols
|
|
152
|
+
if (body.screens !== undefined) {
|
|
153
|
+
// keep every screen a symbol names, so no symbol falls onto the first screen
|
|
154
|
+
const list = cleanScreens(body.screens)
|
|
155
|
+
target.screens = list.length ? cleanScreens([...list, ...symbols.map(m => m.screen).filter(Boolean)]) : list
|
|
156
|
+
}
|
|
157
|
+
const saved = await adminRequest(RED, req, 'POST', { flows, rev: current.json.rev })
|
|
158
|
+
if (saved.status !== 200) {
|
|
159
|
+
return res.status(saved.status).json({ error: (saved.json && saved.json.message) || 'Deploy failed', code: 'POST_FAILED' })
|
|
160
|
+
}
|
|
161
|
+
editKeys.delete(id)
|
|
162
|
+
res.json({ ok: true, rev: saved.json && saved.json.rev })
|
|
163
|
+
} catch (err) {
|
|
164
|
+
res.status(500).json({ error: err.message, code: 'ERROR' })
|
|
165
|
+
}
|
|
166
|
+
})
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
module.exports.cleanSymbols = cleanSymbols
|
|
170
|
+
module.exports.cleanScreens = cleanScreens
|