Skip to main content

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.

Difference from 「Example Collection」

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 nameWhat it doesPrerequisitesDownload
PlantPulse 노드 검증 (Node Check)Verifies that the Write Tag Value and Read Tag Value nodes workREST API key, a writable test tagplantpulse-node-check.json
Edge MQTT 수신 (MQTT Receive)Receives tag publications (/edge/point) from the built-in broker, checks a threshold, and forwards them over WebSocketMQTT publishing enablededge-mqtt-receive.json
Edge 시스템 모니터링 (System Monitoring)Logs in every 30 seconds and reads system metrics such as CPU, memory, and diskWeb UI login accountedge-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 itThe System Monitoring tab must already be runningedge-data-collection.json
REST APIServes the tags and metrics gathered above at /tags · /metricsSystem Monitoring + Data Collection tabsrest-api.json
DashboardGauges, tables, and a real-time monitor screenSystem Monitoring + Data Collection tabsdashboard.json
All six tabs aboveImport everything at onceAll of the aboveall-flows.json
How the Tabs Connect

The tabs exchange values with each other through Node-RED's global context.

  • The System Monitoring tab stores the login session in jsession and the system metrics in metrics_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​

  1. Open the editor via Flow (Node-RED) in the left menu. (Getting Started with Flows)
  2. 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.)
  3. In Node-RED, open the top-right Menu (☰) → Import.
  4. Paste into the input field on the Clipboard tab. (To use a file, select a file to import also works.)
  5. Leave Import to set to new flow. A new tab is created instead of mixing into the current tab.
  6. Click Import, change the values in Section 3 below, then click Deploy at the top right.
  7. 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.

PlaceholderWhere it isReplace with
<EDGE_REST_API_KEY>The Write Tag Value · Read Tag Value nodes in the Node Check tab (double-click → API Key field). Three placesThe 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
Values Remain in Plain Text in the Flow File

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.

A Wrong Password Locks the Account

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

RowFlow
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.

The Write Trigger Actually Writes a Value

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.

If There Are Many Tags

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.

PathContents
GET /tagsAll collected tags (count, data, ts)
GET /metricsSystem 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.

These Paths Have No Authentication

/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.

ScreenContents
시스템 모니터 (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.

What Comes Along When You Import This Tab

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.

SituationScreenWhat to do
Importing the same flow againThe Import dialog shows an 「existing nodes」 warning and Import to splitsChoose 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 existThe same warningImporting 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 twiceA path in use-type error on deployThe 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 existTwo http in nodes with the same pathDelete one or change the path
Dashboard settings already existTwo ui_base nodesKeep the existing settings and skip the imported ui_base in the Import dialog

7. Troubleshooting​

SymptomCheck
unknown node warning on importLook 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 unauthorizedAPI Key empty or wrong — Section 3
Write/read node shows tag_id emptyThe inject's tag_id was cleared, or no tag is specified in the node properties
로그인 실패 in System MonitoringAccount 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 emptyCheck for wss://…:1880/ws/edge connection errors in the browser developer tools

Next Steps​