Example Flows for Import
Node-RED on the gateway ships with a blank canvas. No example flows are included in the default installation — a deliberate decision to keep the first screen clean. Instead, this page provides six examples you can import and run right away. Copy the JSON and paste it into Node-RED to create a tab, then click Deploy to run it.
These examples are self-contained within a single gateway. All addresses are 127.0.0.1 (the gateway itself), and MQTT 1883 is the gateway's built-in broker. If you import the flows into Node-RED on the gateway, there is no need to change any addresses.
Example Collection explains patterns with text and code snippets. The examples on this page are complete flows whose nodes are already wired the moment you import them.
1. At a Glance
| Tab name | What it does | Prerequisites | Download |
|---|---|---|---|
| PlantPulse 노드 검증 (Node Check) | Verifies that the Write Tag Value and Read Tag Value nodes work | REST API key, a writable test tag | plantpulse-node-check.json |
| Edge MQTT 수신 (MQTT Receive) | Receives tag publications (/edge/point) from the built-in broker, checks a threshold, and forwards them over WebSocket | MQTT publishing enabled | edge-mqtt-receive.json |
| Edge 시스템 모니터링 (System Monitoring) | Logs in every 30 seconds and reads system metrics such as CPU, memory, and disk | Web UI login account | edge-system-monitoring.json |
| Edge 데이터 수집 (Data Collection) | Every 5 seconds, gathers the current values of tags from all OPCs into a single bundle, stores it, and pushes it | The System Monitoring tab must already be running | edge-data-collection.json |
| REST API | Serves the tags and metrics gathered above at /tags · /metrics | System Monitoring + Data Collection tabs | rest-api.json |
| Dashboard | Gauges, tables, and a real-time monitor screen | System Monitoring + Data Collection tabs | dashboard.json |
| All six tabs above | Import everything at once | All of the above | all-flows.json |
The tabs exchange values with each other through Node-RED's global context.
- The System Monitoring tab stores the login session in
jsessionand the system metrics inmetrics_cache. - The Data Collection tab uses that session to read tags and stores them in
tags_cache. - The REST API and Dashboard tabs only read
metrics_cache·tags_cache.
Therefore, import and deploy them in the order System Monitoring → Data Collection → (REST API · Dashboard). If the order is reversed, the values are simply empty — it is not an error; they fill in once the preceding tab has run one cycle. The global context lives only in memory and is cleared when Node-RED restarts.
2. How to Import
- Open the editor via Flow (Node-RED) in the left menu. (Getting Started with Flows)
- On this page, expand View JSON for the tab you want to import, and click the Copy button on the code box. (Or download the file from the table above and copy its contents.)
- In Node-RED, open the top-right Menu (☰) → Import.
- Paste into the input field on the Clipboard tab. (To use a file, select a file to import also works.)
- Leave Import to set to new flow. A new tab is created instead of mixing into the current tab.
- Click Import, change the values in Section 3 below, then click Deploy at the top right.
- Check the results in the debug tab of the right sidebar.
3. Values to Change After Import
Secrets in the examples are left blank as placeholders. Enter the actual values yourself.
| Placeholder | Where it is | Replace with |
|---|---|---|
<EDGE_REST_API_KEY> | The Write Tag Value · Read Tag Value nodes in the Node Check tab (double-click → API Key field). Three places | The edge.rest.api.key value in the gateway's app.properties. For where it is set and how to enter it, see REST API Authentication and Credentials |
<USER_ID> · <PASSWORD> | The 「로그인 준비」 (Prepare Login) function node in the System Monitoring tab (one line in the code) | The account used to log in to the gateway web UI |
The API Key and password are stored as-is in flows.json, and they are also included in the JSON when you Export the flow. Do not paste flows containing these values into messengers, issues, or documents. The JSON on this page contains no values.
Login is protected against brute-force attacks. Five failures from the same source IP with the same user ID lock it for 5 minutes. The System Monitoring tab logs in every 30 seconds, so deploying it with a wrong password locks the account within 2 minutes 30 seconds. Because Node-RED logs in from the edge itself (127.0.0.1), only the «127.0.0.1 + that ID» combination is locked; logging in with the same ID from another PC is not blocked. Verify the values before deploying, and if they are wrong, disable the tab immediately.
4. Tab Descriptions
PlantPulse 노드 검증 (Node Check)
Purpose — Verifies that the Write Tag Value · Read Tag Value nodes in the PlantPulse Edge node category work. For node descriptions, see Edge-specific Nodes.
Structure
| Row | Flow |
|---|---|
| Write then verify | 쓰기 trigger (4242) → Write Tag Value → 쓰기 결과 (debug) and 쓰기 후 readback (Read Tag Value, Fresh on) → readback 결과 |
| Simple read | 단순 읽기 trigger → Read Tag Value (cache) → 캐시 read 결과 |
Requirements — A tag named TAG_TEST_00042 must exist and must be a writable tag. To use a different tag, change the tag_id value in both inject nodes. Also enter the API Key from Section 3.
Clicking 쓰기 trigger (4242) writes 4242 to that tag. Click it only on test tags or simulator tags, not on real equipment tags.
What you see — Click the button on the left of an inject node and the result appears in the debug sidebar. The write result should show result: "SUCCESS" and the value re-read by the driver right after writing (actual_read), and the readback result should show value: "4242". If the status text under a node reads 401 unauthorized — check api_key, the API Key is wrong or empty.
View JSON
[
{
"id": "pp-test-tab",
"type": "tab",
"label": "PlantPulse 노드 검증",
"disabled": false,
"info": "엣지 카테고리의 [태그값 읽기] [태그값 쓰기] 노드 자동 검증 플로우.\n각 inject → node → debug 로 확인."
},
{
"id": "pp-tw-inject",
"type": "inject",
"z": "pp-test-tab",
"name": "쓰기 trigger (4242)",
"props": [
{
"p": "payload"
},
{
"p": "tag_id",
"v": "TAG_TEST_00042",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"topic": "",
"payload": "4242",
"payloadType": "str",
"x": 180,
"y": 100,
"wires": [
[
"pp-tw"
]
]
},
{
"id": "pp-tw",
"type": "pp-tag-value-write",
"z": "pp-test-tab",
"name": "",
"value": "",
"host": "127.0.0.1",
"port": "80",
"x": 410,
"y": 100,
"wires": [
[
"pp-tw-debug",
"pp-tr-after-write"
]
],
"tag_id": "",
"api_key": "<EDGE_REST_API_KEY>"
},
{
"id": "pp-tw-debug",
"type": "debug",
"z": "pp-test-tab",
"name": "쓰기 결과",
"active": true,
"tosidebar": true,
"complete": "payload",
"targetType": "msg",
"x": 800,
"y": 80,
"wires": []
},
{
"id": "pp-tr-after-write",
"type": "pp-tag-value-read",
"z": "pp-test-tab",
"name": "쓰기 후 readback",
"fresh": true,
"host": "127.0.0.1",
"port": "80",
"x": 800,
"y": 280,
"wires": [
[
"pp-tr-debug"
]
],
"tag_id": "",
"api_key": "<EDGE_REST_API_KEY>"
},
{
"id": "pp-tr-debug",
"type": "debug",
"z": "pp-test-tab",
"name": "readback 결과",
"active": true,
"tosidebar": true,
"complete": "payload",
"targetType": "msg",
"x": 1000,
"y": 140,
"wires": []
},
{
"id": "pp-tr-only-inject",
"type": "inject",
"z": "pp-test-tab",
"name": "단순 읽기 trigger",
"props": [
{
"p": "tag_id",
"v": "TAG_TEST_00042",
"vt": "str"
}
],
"repeat": "",
"crontab": "",
"once": false,
"onceDelay": 0.1,
"x": 190,
"y": 220,
"wires": [
[
"pp-tr-only"
]
]
},
{
"id": "pp-tr-only",
"type": "pp-tag-value-read",
"z": "pp-test-tab",
"name": "",
"fresh": false,
"host": "127.0.0.1",
"port": "80",
"x": 410,
"y": 220,
"wires": [
[
"pp-tr-only-debug"
]
],
"tag_id": "",
"api_key": "<EDGE_REST_API_KEY>"
},
{
"id": "pp-tr-only-debug",
"type": "debug",
"z": "pp-test-tab",
"name": "캐시 read 결과",
"active": true,
"tosidebar": true,
"complete": "payload",
"targetType": "msg",
"x": 760,
"y": 440,
"wires": []
}
]
Edge MQTT 수신 (MQTT Receive)
Purpose — Subscribes to the tag values the gateway publishes to its built-in broker (mqtt.server.topic, default /edge/point), extracts and displays the tag and value, and raises an alarm when the value exceeds 100. The same values are also sent over WebSocket (/ws/edge) so that external screens can subscribe.
Structure — /edge/point 구독 (mqtt in, QoS 1) → 태그/값 추출 → 수신 데이터 (debug) / 임계값 초과? (value > 100) → 임계값 초과 경보 (debug) / WebSocket /ws/edge.
Requirements — MQTT publishing must be enabled (mqtt.enable). The built-in broker does not accept anonymous connections. After importing, be sure to enter mqtt.server.user · mqtt.server.password from the edge's app.properties in the Security tab of the Edge MQTT (127.0.0.1:1883) broker configuration — see MQTT Node. The threshold of 100 is an example; change it in the 임계값 초과? node.
What you see — The debug sidebar shows one entry per tag in the form { tag, value, timestamp }, and the status under the node shows the last value. If the subscribe node is not connected (green), check the broker address and authentication.
View JSON
[
{
"id": "tab_edge_mqtt",
"type": "tab",
"label": "Edge MQTT 수신",
"disabled": false,
"info": "",
"env": []
},
{
"id": "n_mqtt_in",
"type": "mqtt in",
"z": "tab_edge_mqtt",
"name": "/edge/point 구독",
"topic": "/edge/point",
"qos": "1",
"datatype": "json",
"broker": "cfg_edge_mqtt_broker",
"nl": false,
"rap": true,
"rh": 0,
"inputs": 0,
"x": 150,
"y": 80,
"wires": [
[
"n_func_extract"
]
]
},
{
"id": "n_func_extract",
"type": "function",
"z": "tab_edge_mqtt",
"name": "태그/값 추출",
"func": "var p = msg.payload;\nmsg.topic = p.tag_id || p.tag_name || p.tag || p.tagName || p.tagId || 'unknown';\nmsg.payload = {\n tag: p.tag_id || p.tag_name || p.tag || p.tagName || p.tagId,\n value: p.value,\n timestamp: p.timestamp || p.ts,\n edge_id: p.edge_id || p.edgeId\n};\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 380,
"y": 80,
"wires": [
[
"n_debug_mqtt",
"n_switch_alarm",
"n_ws_out"
]
]
},
{
"id": "n_debug_mqtt",
"type": "debug",
"z": "tab_edge_mqtt",
"name": "수신 데이터",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": true,
"complete": "payload",
"targetType": "msg",
"statusVal": "payload.value",
"statusType": "auto",
"x": 620,
"y": 60,
"wires": []
},
{
"id": "n_switch_alarm",
"type": "switch",
"z": "tab_edge_mqtt",
"name": "임계값 초과?",
"property": "payload.value",
"propertyType": "msg",
"rules": [
{
"t": "gt",
"v": "100",
"vt": "num"
}
],
"checkall": "true",
"repair": false,
"outputs": 1,
"x": 620,
"y": 140,
"wires": [
[
"n_debug_alarm"
]
]
},
{
"id": "n_debug_alarm",
"type": "debug",
"z": "tab_edge_mqtt",
"name": "⚠️ 임계값 초과 경보",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": false,
"complete": "payload",
"targetType": "msg",
"statusVal": "",
"statusType": "auto",
"x": 860,
"y": 140,
"wires": []
},
{
"id": "n_ws_out",
"type": "websocket out",
"z": "tab_edge_mqtt",
"name": "WebSocket /ws/edge",
"server": "cfg_ws_listener",
"client": "",
"x": 620,
"y": 200,
"wires": []
},
{
"id": "cfg_edge_mqtt_broker",
"type": "mqtt-broker",
"name": "Edge MQTT (127.0.0.1:1883)",
"broker": "127.0.0.1",
"port": "1883",
"clientid": "nodered-edge-client",
"usetls": false,
"compatmode": false,
"keepalive": "60",
"cleansession": true
},
{
"id": "cfg_ws_listener",
"type": "websocket-listener",
"path": "/ws/edge",
"wholemsg": "false"
}
]
Edge 시스템 모니터링 (System Monitoring)
Purpose — Calls /monitor/bean with a session logged in to the web UI to obtain system metrics. This tab is the source of the session (jsession) and metrics (metrics_cache) used by the other tabs.
Structure — 30초 주기 (runs once immediately 1 second after deploy) → 로그인 준비 → POST /login/login → 쿠키 추출 → 모니터 요청 → GET /monitor/bean → 메트릭 추출 → 시스템 메트릭 (debug).
Requirements — <USER_ID> · <PASSWORD> from Section 3.
What you see — Debug shows a single object containing CPU, memory, and disk usage, tag count, OPC count, MPS (messages per second), PLC connected/disconnected counts, temperature, and network IP. If login fails, a 로그인 실패 (login failed) warning appears and the subsequent steps stop.
View JSON
[
{
"id": "tab_edge_monitor",
"type": "tab",
"label": "Edge 시스템 모니터링",
"disabled": false,
"info": "",
"env": []
},
{
"id": "n_inject_mon",
"type": "inject",
"z": "tab_edge_monitor",
"name": "30초 주기",
"props": [
{
"p": "payload"
}
],
"repeat": "30",
"crontab": "",
"once": true,
"onceDelay": "1",
"topic": "",
"payload": "",
"payloadType": "date",
"x": 130,
"y": 100,
"wires": [
[
"n_func_login_prep"
]
]
},
{
"id": "n_func_login_prep",
"type": "function",
"z": "tab_edge_monitor",
"name": "로그인 준비",
"func": "msg.url = 'http://127.0.0.1/login/login';\nmsg.method = 'POST';\nmsg.payload = 'user_id=<USER_ID>&password=<PASSWORD>';\nmsg.headers = {'Content-Type': 'application/x-www-form-urlencoded'};\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 310,
"y": 100,
"wires": [
[
"n_http_login"
]
]
},
{
"id": "n_http_login",
"type": "http request",
"z": "tab_edge_monitor",
"name": "POST /login/login",
"method": "use",
"ret": "txt",
"paytoqs": "ignore",
"url": "",
"tls": "",
"persist": true,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 510,
"y": 100,
"wires": [
[
"n_func_cookie"
]
]
},
{
"id": "n_func_cookie",
"type": "function",
"z": "tab_edge_monitor",
"name": "쿠키 추출 → 모니터 요청",
"func": "var sid = null;\nvar c = msg.responseCookies;\nif (c && c.PP_EDGE_JSESSIONID) {\n sid = c.PP_EDGE_JSESSIONID.value || c.PP_EDGE_JSESSIONID;\n}\nif (!sid) {\n var sc = msg.headers && (msg.headers['set-cookie'] || msg.headers['Set-Cookie']);\n if (sc) {\n var m = (Array.isArray(sc) ? sc.join(';') : sc).match(/PP_EDGE_JSESSIONID=([^;]+)/);\n if (m) sid = m[1];\n }\n}\nif (!sid) { node.warn('로그인 실패: ' + JSON.stringify(msg.payload)); return null; }\n\n// global에도 저장 → 다른 탭에서 재사용\nglobal.set('jsession', sid);\nflow.set('jsession', sid);\n\nmsg.url = 'http://127.0.0.1/monitor/bean';\nmsg.method = 'GET';\nmsg.payload = '';\nmsg.headers = { Cookie: 'PP_EDGE_JSESSIONID=' + sid };\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 730,
"y": 100,
"wires": [
[
"n_http_mon"
]
]
},
{
"id": "n_http_mon",
"type": "http request",
"z": "tab_edge_monitor",
"name": "GET /monitor/bean",
"method": "use",
"ret": "obj",
"paytoqs": "ignore",
"url": "",
"tls": "",
"persist": true,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 980,
"y": 100,
"wires": [
[
"n_func_metrics"
]
]
},
{
"id": "n_func_metrics",
"type": "function",
"z": "tab_edge_monitor",
"name": "메트릭 추출",
"func": "var d = msg.payload && msg.payload.data;\nif (!d) { node.warn('데이터 없음: ' + JSON.stringify(msg.payload)); return null; }\nmsg.payload = {\n cpu: d.cpu_used_percent.toFixed(1) + '%',\n memory: d.memory_used_percent.toFixed(1) + '%',\n disk: d.disk_used_percent.toFixed(1) + '%',\n tag_count: d.tag_count,\n opc_count: d.opc_count,\n mps: d.mps,\n plc_connected: d.plc_con_connected_count,\n plc_disconnected: d.plc_con_disconnected_count,\n temperature: d.temperature,\n net_ip: d.net_1_ip\n};\nglobal.set('metrics_cache', msg.payload); // 대시보드 · REST API 탭이 읽는다\nreturn msg;",
"outputs": 1,
"timeout": 0,
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 1200,
"y": 100,
"wires": [
[
"n_debug_metrics"
]
]
},
{
"id": "n_debug_metrics",
"type": "debug",
"z": "tab_edge_monitor",
"name": "시스템 메트릭",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": true,
"complete": "payload",
"targetType": "msg",
"statusVal": "payload.cpu",
"statusType": "auto",
"x": 1420,
"y": 100,
"wires": []
}
]
Edge 데이터 수집 (Data Collection)
Purpose — Every 5 seconds, gathers the tag lists and current values of all registered OPCs into a single array (tags_cache) and pushes it over WebSocket (/ws/edge).
Structure — 5초 태그 조회 → OPC 목록 조회 준비 → OPC 목록 조회 → OPC별 태그 조회 분기 (one message per OPC) → 태그 목록 조회 → 태그 수집 및 합산 → 전체 태그 현재값 (debug) / WebSocket push /ws/edge.
Requirements — The System Monitoring tab must have logged in first. Without a session, only the 세션 없음 - 모니터 탭 로그인 대기중 (no session — waiting for monitor tab login) warning appears and the cycle is skipped. When the session expires, the tab clears it itself, and the System Monitoring tab logs in again on its next cycle (30 seconds).
What you see — 전체 태그 N개 수집 완료 (N tags collected) appears every 5 seconds; expand it to see each tag's id · opc_id · value · type · time · status · address.
On boxes with many tags, scanning all OPCs every 5 seconds becomes a burden. With several thousand tags or more, lengthen the interval (the repeat interval of 5초 태그 조회).
View JSON
[
{
"id": "tab_opcua_read",
"type": "tab",
"label": "Edge 데이터 수집",
"disabled": false,
"info": "",
"env": []
},
{
"id": "inj_taglist",
"type": "inject",
"z": "tab_opcua_read",
"name": "5초 태그 조회",
"props": [
{
"p": "payload"
}
],
"repeat": "5",
"once": true,
"onceDelay": 3,
"payload": "",
"payloadType": "date",
"x": 130,
"y": 160,
"wires": [
[
"fn_opc_list_prep"
]
]
},
{
"id": "fn_opc_list_prep",
"type": "function",
"z": "tab_opcua_read",
"name": "OPC 목록 조회 준비",
"func": "var sid = global.get('jsession');\nif (!sid) {\n node.warn('세션 없음 - 모니터 탭 로그인 대기중');\n return null;\n}\nmsg.url = 'http://127.0.0.1/connect/opc/list';\nmsg.method = 'POST';\nmsg.payload = JSON.stringify({});\nmsg.headers = {\n 'Content-Type': 'application/json',\n 'Cookie': 'PP_EDGE_JSESSIONID=' + sid\n};\nreturn msg;",
"outputs": 1,
"x": 330,
"y": 160,
"wires": [
[
"http_opc_list"
]
]
},
{
"id": "http_opc_list",
"type": "http request",
"z": "tab_opcua_read",
"name": "OPC 목록 조회",
"method": "use",
"ret": "obj",
"paytoqs": "ignore",
"url": "",
"x": 540,
"y": 160,
"wires": [
[
"fn_opc_list_split"
]
]
},
{
"id": "fn_opc_list_split",
"type": "function",
"z": "tab_opcua_read",
"name": "OPC별 태그 조회 분기",
"func": "var d = msg.payload;\nif (!d || d.result !== 'OK') {\n node.warn('OPC 목록 조회 실패: ' + JSON.stringify(d && d.message || d));\n if (d && d.result === 'ERROR') global.set('jsession', null);\n return null;\n}\nvar opcs = (d.data && d.data.data) ? d.data.data : [];\nvar sid = global.get('jsession');\n\n// 총 OPC 수를 flow context에 저장 (합산용)\nflow.set('opc_total', opcs.length);\nflow.set('opc_done', 0);\nflow.set('all_tags', []);\n\nvar msgs = opcs.map(function(opc) {\n return {\n url: 'http://127.0.0.1/connect/tag/list',\n method: 'POST',\n payload: JSON.stringify({ opc_id: opc.opc_id }),\n headers: {\n 'Content-Type': 'application/json',\n 'Cookie': 'PP_EDGE_JSESSIONID=' + sid\n },\n opc_id: opc.opc_id\n };\n});\nreturn [msgs];",
"outputs": 1,
"x": 760,
"y": 160,
"wires": [
[
"http_taglist"
]
]
},
{
"id": "http_taglist",
"type": "http request",
"z": "tab_opcua_read",
"name": "태그 목록 조회",
"method": "use",
"ret": "obj",
"paytoqs": "ignore",
"url": "",
"x": 980,
"y": 160,
"wires": [
[
"fn_taglist_collect"
]
]
},
{
"id": "fn_taglist_collect",
"type": "function",
"z": "tab_opcua_read",
"name": "태그 수집 및 합산",
"func": "var d = msg.payload;\nif (!d || d.result !== 'OK') {\n node.warn('태그 조회 실패 [' + msg.opc_id + ']: ' + JSON.stringify(d && d.message || d));\n if (d && d.result === 'ERROR') global.set('jsession', null);\n // 실패해도 카운트는 진행\n}\n\nvar rows = (d && d.data && d.data.data) ? d.data.data : [];\nvar tags = rows.map(function(r) {\n return {\n id: r.tag_id,\n opc_id: msg.opc_id,\n value: r.value,\n type: r.data_type,\n time: r.value_time,\n status: r.value_read_status,\n address: r.plc_address\n };\n});\n\n// flow context에 누적\nvar allTags = flow.get('all_tags') || [];\nallTags = allTags.concat(tags);\nflow.set('all_tags', allTags);\n\nvar done = (flow.get('opc_done') || 0) + 1;\nflow.set('opc_done', done);\nvar total = flow.get('opc_total') || 1;\n\nif (done >= total) {\n // 모든 OPC 완료 - global cache 업데이트\n global.set('tags_cache', allTags);\n flow.set('all_tags', []);\n flow.set('opc_done', 0);\n msg.payload = allTags;\n msg.topic = '전체 태그 ' + allTags.length + '개 수집 완료';\n return msg;\n}\n// 아직 수집 중 - 출력 없음\nreturn null;",
"outputs": 1,
"x": 1200,
"y": 160,
"wires": [
[
"dbg_taglist",
"n_ws_out_opc"
]
]
},
{
"id": "dbg_taglist",
"type": "debug",
"z": "tab_opcua_read",
"name": "전체 태그 현재값",
"active": true,
"tosidebar": true,
"console": false,
"tostatus": true,
"complete": "topic",
"targetType": "msg",
"statusVal": "topic",
"statusType": "auto",
"x": 1420,
"y": 160,
"wires": []
},
{
"id": "n_ws_out_opc",
"type": "websocket out",
"z": "tab_opcua_read",
"name": "WebSocket push /ws/edge",
"server": "cfg_ws_listener",
"client": "",
"x": 1420,
"y": 220,
"wires": []
},
{
"id": "cfg_ws_listener",
"type": "websocket-listener",
"path": "/ws/edge",
"wholemsg": "false"
}
]
REST API
Purpose — Node-RED itself becomes a small HTTP server and serves the values gathered by the tabs above to the outside.
| Path | Contents |
|---|---|
GET /tags | All collected tags (count, data, ts) |
GET /metrics | System metrics (data, ts) |
Requirements — System Monitoring · Data Collection tabs. If there are no values yet, /tags returns an empty array and /metrics returns an empty object.
What you see — Receive JSON via a browser or curl -k https://<게이트웨이>:1880/tags. Node-RED responds over HTTPS on 1880.
/tags · /metrics require neither a login nor an API Key. Node-RED opens 1880 on all network interfaces of the edge, so anyone who can reach the edge can read them. Use this only on boxes where 1880 is blocked by a firewall (Network Configuration), and if you intend to expose it externally, add a check in front of http in.
View JSON
[
{
"id": "7ea01498f8d0e9f5",
"type": "tab",
"label": "REST API",
"disabled": false,
"info": "",
"env": []
},
{
"id": "333d07c793050145",
"type": "http in",
"z": "7ea01498f8d0e9f5",
"url": "/tags",
"method": "get",
"upload": false,
"x": 200,
"y": 100,
"wires": [
[
"e6305f931d6d2564"
]
]
},
{
"id": "e6305f931d6d2564",
"type": "function",
"z": "7ea01498f8d0e9f5",
"name": "tags 반환",
"func": "var d=global.get(\"tags_cache\")||[];msg.payload=JSON.stringify({result:\"OK\",count:d.length,data:d,ts:new Date().toISOString()});msg.statusCode=200;msg.headers={\"Content-Type\":\"application/json\"};return msg;",
"outputs": 1,
"x": 420,
"y": 100,
"wires": [
[
"f823a0c68999a359"
]
]
},
{
"id": "f823a0c68999a359",
"type": "http response",
"z": "7ea01498f8d0e9f5",
"x": 640,
"y": 100,
"wires": []
},
{
"id": "dd33b660c211d0b0",
"type": "http in",
"z": "7ea01498f8d0e9f5",
"url": "/metrics",
"method": "get",
"upload": false,
"x": 200,
"y": 180,
"wires": [
[
"a6944b28b153beac"
]
]
},
{
"id": "a6944b28b153beac",
"type": "function",
"z": "7ea01498f8d0e9f5",
"name": "metrics 반환",
"func": "var d=global.get(\"metrics_cache\")||{};msg.payload=JSON.stringify({result:\"OK\",data:d,ts:new Date().toISOString()});msg.statusCode=200;msg.headers={\"Content-Type\":\"application/json\"};return msg;",
"outputs": 1,
"x": 420,
"y": 180,
"wires": [
[
"876a083b1fa547c3"
]
]
},
{
"id": "876a083b1fa547c3",
"type": "http response",
"z": "7ea01498f8d0e9f5",
"x": 640,
"y": 180,
"wires": []
}
]
Dashboard
Purpose — View the values gathered by the tabs above on screen. Three screens (tabs) are created.
| Screen | Contents |
|---|---|
| 시스템 모니터 (System Monitor) | CPU · memory · disk gauges, temperature, tag · OPC · MPS summary |
| OPC-UA 태그 (OPC-UA Tags) | Table of current tag values (tag ID · value · time · status) |
| 태그 모니터 (Tag Monitor) | Full-screen monitor updated in real time over WebSocket (/ws/edge). OPC · tag filters, light/dark toggle |
Requirements — The gateway image includes both Dashboard node sets, so there is nothing to install separately. This example is built with the first-generation node-red-dashboard (ui_* nodes) (not Dashboard 2). Values appear on screen only once the System Monitoring · Data Collection tabs fill them in. If the dashboard category is visible in the palette, you are ready.
Where to view — https://<게이트웨이>:1880/ui. With a self-signed certificate, you need to click through the browser warning once. You can also get there via the open icon on the dashboard tab in the editor's right sidebar.
What you see — Gauges and tables update every 5 seconds. If a gauge stays at 0, the System Monitoring tab is not receiving metrics. If the Tag Monitor stays at WebSocket 연결 대기 중... (waiting for WebSocket connection), check that the Data Collection tab is running.
The Dashboard tab JSON includes both the screen layout (ui_tab · ui_group) and the global settings (ui_base, site name 「Edge 모니터링」). If you already use Dashboard, read the conflict guidance in Section 5 first.
View JSON
[{"id":"063f54dba22818e8","type":"tab","label":"Dashboard","disabled":false,"info":"","env":[]},{"id":"d40e1c4938f31319","type":"inject","z":"063f54dba22818e8","name":"5초 시스템","repeat":"5","once":true,"onceDelay":"2","x":130,"y":260,"wires":[["5b2612f9157a4313"]]},{"id":"5b2612f9157a4313","type":"function","z":"063f54dba22818e8","name":"메트릭→UI","func":"var m=global.get(\"metrics_cache\");if(!m)return null;return [{payload:parseFloat(m.cpu)||0},{payload:parseFloat(m.memory)||0},{payload:parseFloat(m.disk)||0},{payload:m.temperature||\"\"},{payload:\"Tags:\"+m.tag_count+\" | OPC:\"+m.opc_count+\" | MPS:\"+m.mps}];","outputs":5,"x":370,"y":260,"wires":[["00a9ff55589947e6"],["3108d96cea4a6e88"],["9d75bbf4db9b5c4d"],["84893137b115e8f2"],["cea619bd887d8af7"]]},{"id":"00a9ff55589947e6","type":"ui_gauge","z":"063f54dba22818e8","name":"CPU","group":"543ed5643d3782f9","order":1,"width":2,"height":2,"gtype":"gage","title":"CPU","label":"%","format":"{{value}}","min":0,"max":100,"colors":["#00b500","#e6e600","#ca3838"],"seg1":50,"seg2":80,"x":590,"y":180,"wires":[]},{"id":"3108d96cea4a6e88","type":"ui_gauge","z":"063f54dba22818e8","name":"메모리","group":"543ed5643d3782f9","order":2,"width":2,"height":2,"gtype":"gage","title":"메모리","label":"%","format":"{{value}}","min":0,"max":100,"colors":["#00b500","#e6e600","#ca3838"],"seg1":60,"seg2":85,"x":590,"y":220,"wires":[]},{"id":"9d75bbf4db9b5c4d","type":"ui_gauge","z":"063f54dba22818e8","name":"디스크","group":"543ed5643d3782f9","order":3,"width":2,"height":2,"gtype":"gage","title":"디스크","label":"%","format":"{{value}}","min":0,"max":100,"colors":["#00b500","#e6e600","#ca3838"],"seg1":70,"seg2":90,"x":590,"y":260,"wires":[]},{"id":"84893137b115e8f2","type":"ui_text","z":"063f54dba22818e8","group":"54d2e65152d90f3c","order":1,"width":3,"height":1,"name":"온도","format":"🌡️ {{msg.payload}}","layout":"row-spread","className":"","style":false,"font":"","fontSize":16,"color":"#717171","x":590,"y":300,"wires":[]},{"id":"cea619bd887d8af7","type":"ui_text","z":"063f54dba22818e8","group":"54d2e65152d90f3c","order":2,"width":6,"height":1,"name":"정보","format":"📊 {{msg.payload}}","layout":"row-spread","className":"","style":false,"font":"","fontSize":14,"color":"#717171","x":590,"y":340,"wires":[]},{"id":"af20f269db5617dd","type":"inject","z":"063f54dba22818e8","name":"5초 태그","repeat":"5","once":true,"onceDelay":"3","x":120,"y":440,"wires":[["21bb5015cd9e1028"]]},{"id":"21bb5015cd9e1028","type":"function","z":"063f54dba22818e8","name":"태그→UI","func":"var d=global.get(\"tags_cache\")||[];if(!d.length)return null;return {payload:d};","outputs":1,"x":380,"y":440,"wires":[["1d8b962138dec5e1"]]},{"id":"1d8b962138dec5e1","type":"ui_template","z":"063f54dba22818e8","group":"a68ba43d50233268","name":"태그 테이블","order":1,"width":"12","height":"8","format":"<style>\n .tag-table { width:100%; border-collapse:collapse; font-size:13px; }\n .tag-table th { background:#0094CE; color:#fff; padding:7px 10px; text-align:left; }\n .tag-table td { padding:6px 10px; border-bottom:1px solid #eee; }\n .tag-table tr:nth-child(even) td { background:#f5f5f5; }\n .val-ok { color:#2e7d32; font-weight:bold; }\n .val-err { color:#c62828; }\n</style>\n<div style=\"overflow:auto; max-height:380px;\">\n <table class=\"tag-table\">\n <thead><tr>\n <th>태그 ID</th><th>값</th><th>시간</th><th>상태</th>\n </tr></thead>\n <tbody>\n <tr ng-repeat=\"t in msg.payload track by t.id\">\n <td>{{t.id}}</td>\n <td>{{t.value}}</td>\n <td>{{t.time | limitTo:19 | limitTo:-8}}</td>\n <td ng-class=\"t.status === 'SUCCESS' ? 'val-ok' : 'val-err'\">{{t.status}}</td>\n </tr>\n </tbody>\n </table>\n</div>","storeOutMessages":false,"resendOnRefresh":true,"templateScope":"local","className":"","x":610,"y":440,"wires":[[]]},{"id":"n_pp_monitor_tmpl","type":"ui_template","z":"063f54dba22818e8","group":"grp_tag_monitor","name":"태그 모니터 UI","order":1,"width":"12","height":"20","format":"<style>\n #pp-monitor {\n position: fixed; top: 64px; left: 0; width: 100vw; height: calc(100vh - 64px); z-index: 50;\n display: flex; flex-direction: column; overflow: hidden;\n background: var(--bg); color: var(--text);\n font-family: 'Segoe UI', sans-serif;\n transition: background .3s, color .3s;\n --bg: #f5f6fa; --bg2: #ffffff; --bg3: #eef0f6;\n --border: #dde0ea; --text: #1a1d2e; --text2: #5a5f7a; --text3: #9095b0;\n --accent: #0094ce; --ok: #2e7d32; --err: #c62828; --changed: #0077aa;\n --chip-opc-bg:#e8f5e9; --chip-opc-b:#a5d6a7; --chip-opc-t:#2e7d32;\n --chip-tag-bg:#e3f2fd; --chip-tag-b:#90caf9; --chip-tag-t:#1565c0;\n --shadow: 0 2px 8px rgba(0,0,0,.08);\n }\n #pp-monitor.dark,\nbody.nr-dark-theme #pp-monitor {\n --bg: #0f1117; --bg2: #1a1d27; --bg3: #14161f;\n --border: #2a2d3a; --text: #e0e0e0; --text2: #999; --text3: #555;\n --accent: #61dafb; --ok: #4caf50; --err: #f44336; --changed: #61dafb;\n --chip-opc-bg:#1e2a20; --chip-opc-b:#4caf5055; --chip-opc-t:#4caf50;\n --chip-tag-bg:#1e2535; --chip-tag-b:#61dafb55; --chip-tag-t:#61dafb;\n --shadow: 0 2px 8px rgba(0,0,0,.4);\n }\n#pp-monitor * { box-sizing: border-box; margin: 0; padding: 0; }\n \n\n #pp-monitor .pp-header { display:flex; align-items:center; justify-content:space-between;\n padding:12px 20px; background:var(--bg2); border-bottom:1px solid var(--border); flex-shrink:0; }\n header h1 { font-size:17px; font-weight:600; color:var(--accent); }\n#pp-monitor .pp-header-right { display:flex; align-items:center; gap:10px; }\n #pp-monitor .pp-status { display:flex; align-items:center; gap:6px; font-size:12px; padding:3px 10px;\n border-radius:20px; background:var(--bg3); color:var(--text2); }\n#pp-monitor .pp-dot { width:8px; height:8px; border-radius:50%; background:var(--text3); transition:background .3s; }\n#pp-monitor .pp-dot.connected { background:var(--ok); box-shadow:0 0 5px var(--ok); }\n#pp-monitor .pp-dot.disconnected { background:var(--err); }\n#pp-monitor .pp-dot.connecting { background:#ff9800; animation:pp-pulse 1s infinite; }\n @keyframes pp-pulse { 0%,100%{opacity:1}50%{opacity:.3} }\n#pp-monitor .pp-theme-btn { background:none; border:1px solid var(--border); border-radius:6px;\n padding:3px 8px; cursor:pointer; font-size:14px; color:var(--text2); transition:border-color .2s; }\n#pp-monitor .pp-theme-btn:hover { border-color:var(--accent); }\n\n#pp-monitor .pp-filterbar { display:flex; align-items:flex-start; gap:8px; flex-wrap:wrap;\n padding:8px 20px; background:var(--bg3); border-bottom:1px solid var(--border); flex-shrink:0; }\n#pp-monitor .pp-filter-group { position:relative; }\n#pp-monitor .pp-filter-btn { display:flex; align-items:center; gap:5px; padding:5px 10px; border-radius:6px;\n border:1px solid var(--border); background:var(--bg2); color:var(--text2);\n font-size:12px; cursor:pointer; white-space:nowrap; user-select:none; }\n#pp-monitor .pp-filter-btn:hover { border-color:var(--accent); }\n#pp-monitor .pp-filter-btn.active { border-color:var(--accent); color:var(--accent); }\n#pp-monitor .pp-badge { background:var(--accent); color:var(--bg2); border-radius:10px; padding:0 5px; font-size:10px; font-weight:700; }\n#pp-monitor .pp-dropdown { display:none; position:absolute; top:calc(100% + 3px); left:0;\n background:var(--bg2); border:1px solid var(--border); border-radius:8px;\n min-width:210px; max-width:310px; max-height:310px; overflow-y:auto;\n z-index:100; box-shadow:var(--shadow); }\n#pp-monitor .pp-dropdown.open { display:block; }\n#pp-monitor .pp-dd-search { width:100%; padding:7px 10px; background:var(--bg3); border:none;\n border-bottom:1px solid var(--border); color:var(--text); font-size:12px; outline:none;\n position:sticky; top:0; }\n#pp-monitor .pp-dd-actions { display:flex; border-bottom:1px solid var(--border); position:sticky; top:33px; background:var(--bg2); }\n#pp-monitor .pp-dd-actions button { flex:1; padding:4px; background:none; border:none; color:var(--text3); font-size:11px; cursor:pointer; }\n#pp-monitor .pp-dd-actions button:hover { color:var(--accent); }\n#pp-monitor .pp-dd-item { display:flex; align-items:center; gap:8px; padding:6px 12px; cursor:pointer; font-size:12px; }\n#pp-monitor .pp-dd-item:hover { background:var(--bg3); }\n#pp-monitor .pp-dd-item input[type=checkbox] { accent-color:var(--accent); cursor:pointer; flex-shrink:0; }\n#pp-monitor .pp-dd-item label { cursor:pointer; flex:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--text); }\n#pp-monitor .pp-dd-item .pp-cnt { font-size:10px; color:var(--text3); flex-shrink:0; }\n#pp-monitor .pp-dd-hint { padding:5px 12px; font-size:11px; color:var(--accent); border-bottom:1px solid var(--border); background:var(--bg3); }\n#pp-monitor .pp-dd-empty { padding:10px; font-size:11px; color:var(--text3); text-align:center; }\n#pp-monitor .pp-search-input { padding:5px 10px; border-radius:6px; border:1px solid var(--border);\n background:var(--bg2); color:var(--text); font-size:12px; width:180px; }\n#pp-monitor .pp-search-input:focus { outline:none; border-color:var(--accent); }\n#pp-monitor .pp-toolbar-right { margin-left:auto; display:flex; align-items:center; gap:12px; }\n#pp-monitor .pp-chk-label { font-size:12px; color:var(--text2); cursor:pointer; white-space:nowrap; }\n#pp-monitor .pp-chk-label input { accent-color:var(--accent); }\n#pp-monitor .pp-count { font-size:12px; color:var(--text3); }\n#pp-monitor .pp-update { font-size:11px; color:var(--text3); }\n\n#pp-monitor .pp-chips { display:flex; flex-wrap:wrap; gap:5px; padding:0 20px 6px; flex-shrink:0; }\n#pp-monitor .pp-chips:empty { display:none; }\n#pp-monitor .pp-chip { display:flex; align-items:center; gap:3px; padding:2px 7px; border-radius:12px; font-size:11px; border:1px solid; }\n#pp-monitor .pp-chip.opc { background:var(--chip-opc-bg); border-color:var(--chip-opc-b); color:var(--chip-opc-t); }\n#pp-monitor .pp-chip.tag { background:var(--chip-tag-bg); border-color:var(--chip-tag-b); color:var(--chip-tag-t); }\n#pp-monitor .pp-chip .rm { cursor:pointer; opacity:.6; margin-left:2px; }\n#pp-monitor .pp-chip .rm:hover { opacity:1; color:var(--err); }\n\n#pp-monitor .pp-grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(180px,1fr));\n gap:9px; padding:14px 20px; overflow-y:auto; flex:1; align-content:start; }\n#pp-monitor .pp-card { background:var(--bg2); border:1px solid var(--border); border-radius:8px;\n padding:10px 12px; transition:border-color .5s,background .5s; box-shadow:var(--shadow); }\n#pp-monitor .pp-card.updated { border-color:var(--accent); background:var(--bg3); }\n#pp-monitor .pp-card .pp-opc-id { font-size:10px; color:var(--accent); margin-bottom:1px; }\n#pp-monitor .pp-card .pp-tag-id { font-size:11px; color:var(--text3); margin-bottom:3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }\n#pp-monitor .pp-card .pp-val { font-size:20px; font-weight:700; color:var(--text); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }\n#pp-monitor .pp-card .pp-val.changed { color:var(--changed); }\n#pp-monitor .pp-card .pp-meta { font-size:10px; color:var(--text3); margin-top:4px; }\n#pp-monitor .pp-ok { color:var(--ok); }\n#pp-monitor .pp-err { color:var(--err); }\n#pp-monitor .pp-empty { grid-column:1/-1; text-align:center; color:var(--text3); font-size:13px; padding:50px 0; }\n </style>\n\n<div id=\"pp-monitor\">\n <!-- Header -->\n <div class=\"pp-header\">\n <span class=\"pp-title\">🌿 실시간 태그 모니터</span>\n <div class=\"pp-header-right\">\n <div class=\"pp-status\">\n <div class=\"pp-dot connecting\" id=\"pp-dot\"></div>\n <span id=\"pp-status-text\">연결 중...</span>\n </div>\n <button class=\"pp-theme-btn\" id=\"pp-theme-btn\" onclick=\"ppToggleTheme()\" title=\"라이트/다크 전환\">🌙</button>\n </div>\n </div>\n\n <!-- Filter Bar -->\n <div class=\"pp-filterbar\">\n <div class=\"pp-filter-group\">\n <div class=\"pp-filter-btn\" id=\"pp-btn-opc\" onclick=\"ppToggleDd('pp-dd-opc')\">\n OPC ID <span class=\"pp-badge\" id=\"pp-badge-opc\" style=\"display:none\">0</span> ▾\n </div>\n <div class=\"pp-dropdown\" id=\"pp-dd-opc\">\n <input class=\"pp-dd-search\" type=\"text\" placeholder=\"OPC 검색...\" oninput=\"ppRenderOpc(this.value)\" />\n <div class=\"pp-dd-actions\">\n <button onclick=\"ppSelAllOpc()\">전체선택</button>\n <button onclick=\"ppClrOpc()\">전체해제</button>\n </div>\n <div id=\"pp-opc-list\"></div>\n </div>\n </div>\n\n <div class=\"pp-filter-group\">\n <div class=\"pp-filter-btn\" id=\"pp-btn-tag\" onclick=\"ppToggleDd('pp-dd-tag')\">\n Tag ID <span class=\"pp-badge\" id=\"pp-badge-tag\" style=\"display:none\">0</span> ▾\n </div>\n <div class=\"pp-dropdown\" id=\"pp-dd-tag\">\n <input class=\"pp-dd-search\" type=\"text\" placeholder=\"태그 검색...\" oninput=\"ppRenderTag(this.value)\" />\n <div class=\"pp-dd-actions\">\n <button onclick=\"ppSelAllTag()\">전체선택</button>\n <button onclick=\"ppClrTag()\">전체해제</button>\n </div>\n <div id=\"pp-tag-hint\"></div>\n <div id=\"pp-tag-list\"></div>\n </div>\n </div>\n\n <input class=\"pp-search-input\" type=\"text\" id=\"pp-search\" placeholder=\"태그 텍스트 검색...\" oninput=\"ppApply()\" />\n\n <div class=\"pp-toolbar-right\">\n <label class=\"pp-chk-label\"><input id=\"pp-chk-changed\" type=\"checkbox\" onchange=\"ppApply()\" /> 변경만</label>\n <span class=\"pp-count\" id=\"pp-count\">태그: 0</span>\n <span class=\"pp-update\" id=\"pp-update\"></span>\n </div>\n </div>\n\n <!-- Chips -->\n <div class=\"pp-chips\" id=\"pp-chips\"></div>\n\n <!-- Grid -->\n <div class=\"pp-grid\" id=\"pp-grid\"><div class=\"pp-empty\">WebSocket 연결 대기 중...</div></div>\n</div>\n\n<script>\n(function() {\nvar WS_URL = (location.protocol === 'https:' ? 'wss://' : 'ws://') + location.hostname + ':1880/ws/edge';\n var RECONNECT_DELAY = 3000;\n var tags = {}, changedSet = new Set(), ws;\n var selOpc = new Set(), selTag = new Set();\n\n // Theme\n function ppToggleTheme() {\n var dark = !document.getElementById('pp-monitor').classList.contains('dark');\n document.getElementById('pp-monitor').classList.toggle('dark', dark);\n document.getElementById('pp-theme-btn').textContent = dark ? '☀️' : '🌙';\n }\n\n // WebSocket\n function ppConnect() {\n ppSetStatus('pp-dot connecting', '연결 중...');\n ws = new WebSocket(WS_URL);\n ws.onopen = () => ppSetStatus('pp-dot connected', '연결됨');\n ws.onclose = () => { ppSetStatus('pp-dot disconnected', '연결 끊김'); setTimeout(ppConnect, RECONNECT_DELAY); };\n ws.onerror = () => ws.close();\n ws.onmessage = ({ data }) => {\n try {\n var arr = JSON.parse(data); if (!Array.isArray(arr)) return;\n changedSet.clear();\n arr.forEach(t => { var id=t.id||t.tag; if(!id) return;\n if(id in tags && String(tags[id].value)!==String(t.value)) changedSet.add(id);\n tags[id] = {...t, id}; });\n document.getElementById('pp-update').textContent = '수신: '+new Date().toLocaleTimeString('ko-KR');\n ppRenderOpc(document.querySelector('#pp-dd-opc .pp-dd-search').value);\n ppRenderTag(document.querySelector('#pp-dd-tag .pp-dd-search').value);\n ppApply();\n } catch(e) { console.error(e); }\n };\n }\n function ppSetStatus(cls,txt){\n document.getElementById('pp-dot').className=cls;\n document.getElementById('pp-status-text').textContent=txt;\n }\n\n // OPC\n function ppGetOpcCounts(){ var c={}; Object.values(tags).forEach(t=>{var o=t.opc_id||'(없음)';c[o]=(c[o]||0)+1;}); return c; }\n function ppRenderOpc(s=''){\n var counts=ppGetOpcCounts(); var q=s.toLowerCase();\n var all=Object.keys(counts).sort().filter(v=>!q||v.toLowerCase().includes(q));\n var el=document.getElementById('pp-opc-list');\n if(!all.length){el.innerHTML='<div class=\"pp-dd-empty\">항목 없음</div>';return;}\n el.innerHTML=all.map(v=>`<div class=\"pp-dd-item\"><input type=\"checkbox\" id=\"oc${v.replace(/\\W/g,'_')}\" ${selOpc.has(v)?'checked':''}\n onchange=\"ppOpcChg('${esc(v)}',this.checked)\"><label for=\"oc${v.replace(/\\W/g,'_')}\" title=\"${v}\">${v}</label>\n <span class=\"pp-cnt\">${counts[v]}</span></div>`).join('');\n }\n function ppOpcChg(v,chk){\n if(chk) selOpc.add(v); else { selOpc.delete(v); var a=ppGetTagsForOpc(); selTag.forEach(t=>{if(!a.has(t))selTag.delete(t);}); }\n ppBadge('opc',selOpc); ppRenderTag(''); ppRenderOpc(''); ppChips(); ppApply();\n }\n function ppSelAllOpc(){ Object.keys(ppGetOpcCounts()).forEach(v=>selOpc.add(v)); ppRenderOpc(''); ppRenderTag(''); ppBadge('opc',selOpc); ppChips(); ppApply(); }\n function ppClrOpc() { selOpc.clear(); ppRenderOpc(''); ppRenderTag(''); ppBadge('opc',selOpc); ppChips(); ppApply(); }\n\n // Tag (cascaded)\n function ppGetTagsForOpc(){ var s=new Set(); Object.values(tags).forEach(t=>{var o=t.opc_id||'(없음)';if(selOpc.size===0||selOpc.has(o))s.add(t.id);}); return s; }\n function ppRenderTag(s=''){\n var q=s.toLowerCase(); var avail=[...ppGetTagsForOpc()].sort().filter(v=>!q||v.toLowerCase().includes(q));\n var hint=document.getElementById('pp-tag-hint'); var el=document.getElementById('pp-tag-list');\n hint.innerHTML=selOpc.size?`<div class=\"pp-dd-hint\">▸ ${[...selOpc].join(', ')} (${ppGetTagsForOpc().size}개)</div>`:'';\n if(!avail.length){el.innerHTML='<div class=\"pp-dd-empty\">항목 없음</div>';return;}\n el.innerHTML=avail.map(v=>`<div class=\"pp-dd-item\"><input type=\"checkbox\" id=\"tc${v.replace(/\\W/g,'_')}\" ${selTag.has(v)?'checked':''}\n onchange=\"ppTagChg('${esc(v)}',this.checked)\"><label for=\"tc${v.replace(/\\W/g,'_')}\" title=\"${v}\">${v}</label></div>`).join('');\n }\n function ppTagChg(v,chk){ if(chk) selTag.add(v); else selTag.delete(v); ppBadge('tag',selTag); ppChips(); ppApply(); }\n function ppSelAllTag(){ ppGetTagsForOpc().forEach(v=>selTag.add(v)); ppRenderTag(''); ppBadge('tag',selTag); ppChips(); ppApply(); }\n function ppClrTag() { selTag.clear(); ppRenderTag(''); ppBadge('tag',selTag); ppChips(); ppApply(); }\n\n function ppBadge(k,s){ var b=document.getElementById('pp-badge-'+k); b.style.display=s.size?'':'none'; b.textContent=s.size;\n document.getElementById('pp-btn-'+k).classList.toggle('active',s.size>0); }\n function ppChips(){\n var p=[];\n selOpc.forEach(v=>p.push(`<div class=\"pp-chip opc\">OPC: ${v} <span class=\"rm\" onclick=\"ppOpcChg('${esc(v)}',false);ppRenderOpc('')\">✕</span></div>`));\n selTag.forEach(v=>p.push(`<div class=\"pp-chip tag\">Tag: ${v} <span class=\"rm\" onclick=\"ppTagChg('${esc(v)}',false);ppRenderTag('')\">✕</span></div>`));\n document.getElementById('pp-chips').innerHTML=p.join('');\n }\n function esc(s){ return s.replace(/\\\\/g,'\\\\\\\\').replace(/'/g,\"\\\\'\"); }\n\n function ppApply(){\n var search=document.getElementById('pp-search').value.toLowerCase();\n var onlyChg=document.getElementById('pp-chk-changed').checked;\n var list=Object.values(tags);\n if(selOpc.size) list=list.filter(t=>selOpc.has(t.opc_id||'(없음)'));\n if(selTag.size) list=list.filter(t=>selTag.has(t.id));\n if(search) list=list.filter(t=>t.id.toLowerCase().includes(search));\n if(onlyChg) list=list.filter(t=>changedSet.has(t.id));\n list.sort((a,b)=>(a.opc_id||'').localeCompare(b.opc_id||'')||a.id.localeCompare(b.id));\n document.getElementById('pp-count').textContent='태그: '+list.length+'/'+Object.keys(tags).length;\n var grid=document.getElementById('pp-grid');\n if(!list.length){ grid.innerHTML='<div class=\"pp-empty\">'+(Object.keys(tags).length?'필터 결과 없음':'WebSocket 연결 대기 중...')+'</div>'; return; }\n grid.innerHTML=list.map(t=>{\n var chg=changedSet.has(t.id);\n var ok=(t.status==='Good'||t.status==='0'||t.status===0);\n var val=(t.value===null||t.value===undefined)?'—':t.value;\n return `<div class=\"pp-card${chg?' updated':''}\" title=\"${t.id}\">\n ${t.opc_id?`<div class=\"pp-opc-id\">${t.opc_id}</div>`:''}\n <div class=\"pp-tag-id\">${t.id}</div>\n <div class=\"pp-val${chg?' changed':''}\">${val}</div>\n <div class=\"pp-meta\"><span class=\"${ok?'pp-ok':'pp-err'}\">${t.status??''}</span>${t.time?' · '+t.time.slice(-8):''}</div>\n </div>`;\n }).join('');\n }\n\n function ppToggleDd(id){\n document.querySelectorAll('.pp-dropdown').forEach(d=>{ if(d.id!==id) d.classList.remove('open'); });\n document.getElementById(id).classList.toggle('open');\n }\n document.addEventListener('click',e=>{ if(!e.target.closest('.pp-filter-group')) document.querySelectorAll('.pp-dropdown').forEach(d=>d.classList.remove('open')); });\n\n ppConnect();\n // Expose functions to global scope for onclick attributes\n window.ppToggleTheme = ppToggleTheme;\n window.ppToggleDd = ppToggleDd;\n window.ppRenderOpc = ppRenderOpc;\n window.ppOpcChg = ppOpcChg;\n window.ppSelAllOpc = ppSelAllOpc;\n window.ppClrOpc = ppClrOpc;\n window.ppRenderTag = ppRenderTag;\n window.ppTagChg = ppTagChg;\n window.ppSelAllTag = ppSelAllTag;\n window.ppClrTag = ppClrTag;\n window.ppApply = ppApply;\n window.ppChips = ppChips;\n\n // Sync with Node-RED dashboard dark theme\n var ppDarkObs = new MutationObserver(function() {\n var sysDark = document.body.classList.contains('nr-dark-theme');\n document.getElementById('pp-monitor').classList.toggle('dark', sysDark);\n document.getElementById('pp-theme-btn').textContent = sysDark ? '☀️' : '🌙';\n });\n ppDarkObs.observe(document.body, { attributes: true, attributeFilter: ['class'] });\n})();\n</script>","storeOutMessages":false,"resendOnRefresh":true,"templateScope":"local","className":"","x":120,"y":120,"wires":[[]]},{"id":"543ed5643d3782f9","type":"ui_group","name":"리소스","tab":"19b7f41773b6f5d3","order":1,"disp":true,"width":"6","collapse":false},{"id":"54d2e65152d90f3c","type":"ui_group","name":"정보","tab":"19b7f41773b6f5d3","order":2,"disp":true,"width":"6","collapse":false},{"id":"a68ba43d50233268","type":"ui_group","name":"태그 현재값","tab":"06645bd4e34ae6fb","order":1,"disp":true,"width":"12","collapse":false},{"id":"grp_tag_monitor","type":"ui_group","name":"실시간 태그","tab":"tab_tag_monitor","order":1,"disp":false,"width":"12","collapse":false},{"id":"19b7f41773b6f5d3","type":"ui_tab","name":"시스템 모니터","icon":"dashboard","disabled":false,"hidden":false},{"id":"06645bd4e34ae6fb","type":"ui_tab","name":"OPC-UA 태그","icon":"label","disabled":false,"hidden":false},{"id":"tab_tag_monitor","type":"ui_tab","name":"태그 모니터","icon":"dashboard","order":3,"disabled":false,"hidden":false},{"id":"2cc7293e4ff5ea15","type":"ui_base","theme":{"name":"theme-light","lightTheme":{"default":"#0094CE","baseColor":"#0094CE","baseFont":"-apple-system","edited":true},"dark":false},"site":{"name":"Edge 모니터링","hideToolbar":"false","allowSwipe":"false","lockMenu":"false","allowTempTheme":"true","dateFormat":"DD/MM/YYYY","sizes":{"sx":48,"sy":48,"gx":6,"gy":6,"cx":6,"cy":6,"px":0,"py":0}}}]
5. Importing Everything at Once
To import all six tabs at once, download all-flows.json (about 43 KB) and import it with select a file to import. Because of its size, this page does not include a code box for it. The four placeholders in Section 3 must be replaced in the same way.
6. Import Conflicts
When you import into a box that is already in use, names may collide.
| Situation | Screen | What to do |
|---|---|---|
| Importing the same flow again | The Import dialog shows an 「existing nodes」 warning and Import to splits | Choose import copy. It creates another one with new IDs instead of overwriting the existing nodes. Avoid overwriting (replace existing nodes), as it loses settings you edited by hand |
| MQTT broker · WebSocket listener config nodes already exist | The same warning | Importing as a copy creates new config nodes. To use the existing ones, change the server field in the imported nodes to the existing one |
/ws/edge opened twice | A path in use-type error on deploy | The Edge MQTT Receive and Edge Data Collection tabs each have their own /ws/edge listener. When imported individually, delete one and have both share the other listener. The full file has only one |
/tags · /metrics paths already exist | Two http in nodes with the same path | Delete one or change the path |
| Dashboard settings already exist | Two ui_base nodes | Keep the existing settings and skip the imported ui_base in the Import dialog |
7. Troubleshooting
| Symptom | Check |
|---|---|
unknown node warning on import | Look at the node type name. If it is pp-*, check that the PlantPulse Edge node category is in the palette; if it is ui_*, check that the dashboard category is there. Both are included in the image by default, so if either is missing, see the Node-RED log (Flow Troubleshooting) |
Write/read node shows 401 unauthorized | API Key empty or wrong — Section 3 |
Write/read node shows tag_id empty | The inject's tag_id was cleared, or no tag is specified in the node properties |
로그인 실패 in System Monitoring | Account error, or locked (Section 3) |
Data Collection shows 세션 없음 | Check that the System Monitoring tab is deployed and logged in successfully |
| Values arrive but the Tag Monitor screen is empty | Check for wss://…:1880/ws/edge connection errors in the browser developer tools |
Next Steps
- Example Collection — Explanations by pattern
- Edge-specific Nodes — All fields of the tag read/write nodes
- Flow Troubleshooting