가져오기용 예제 플로우
게이트웨이의 Node-RED 는 빈 캔버스로 출하됩니다. 기본 설치판에 예제 플로우를 넣지 않았습니다 — 첫 화면을 깔끔하게 두기 위한 결정입니다. 대신 이 페이지에 바로 가져와서 돌려 볼 수 있는 예제 여섯 벌을 둡니다. JSON 을 복사해 Node-RED 에 붙여 넣으면 탭이 생기고, 배포 를 누르면 돕니다.
이 예제들은 게이트웨이 한 대 안에서 완결됩니다. 주소가 전부 127.0.0.1 (게이트웨이 자신)이고, MQTT 1883 은 게이트웨이 내장 브로커입니다. 플로우를 게이트웨이 안의 Node-RED 에서 가져오면 주소를 고칠 필요가 없습니다.
예제 모음 은 패턴을 글과 코드 조각으로 설명합니다. 이 페이지의 예제는 가져오기만 하면 노드가 이미 연결돼 있는 완성 플로우입니다.
1. 한눈에 보기
| 탭 이름 | 하는 일 | 먼저 필요한 것 | 내려받기 |
|---|---|---|---|
| PlantPulse 노드 검증 | 태그값 쓰기·읽기 노드가 동작하는지 확인 | REST API 키, 쓸 수 있는 테스트 태그 | plantpulse-node-check.json |
| Edge MQTT 수신 | 내장 브로커의 태그 발행(/edge/point)을 받아 임계값을 검사하고 WebSocket 으로 내보냄 | MQTT 발행 켜짐 | edge-mqtt-receive.json |
| Edge 시스템 모니터링 | 30초마다 로그인해 CPU·메모리·디스크 등 시스템 지표를 읽음 | 웹 UI 로그인 계정 | edge-system-monitoring.json |
| Edge 데이터 수집 | 5초마다 모든 OPC 의 태그 현재값을 모아 한 덩어리로 보관·푸시 | 시스템 모니터링 탭이 먼저 돌고 있을 것 | edge-data-collection.json |
| REST API | 위에서 모은 태그·지표를 /tags · /metrics 로 내줌 | 시스템 모니터링 + 데이터 수집 탭 | rest-api.json |
| Dashboard | 게이지·표·실시간 모니터 화면 | 시스템 모니터링 + 데이터 수집 탭 | dashboard.json |
| 위 여섯 탭 전부 | 한 번에 가져오기 | 위 조건 전부 | all-flows.json |
탭은 Node-RED 의 글로벌 컨텍스트 로 서로 값을 주고받습니다.
- 시스템 모니터링 탭이 로그인 세션을
jsession, 시스템 지표를metrics_cache에 둡니다. - 데이터 수집 탭이 그 세션으로 태그를 읽어
tags_cache에 둡니다. - REST API 탭과 Dashboard 탭은
metrics_cache·tags_cache를 읽기만 합니다.
그래서 시스템 모니터링 → 데이터 수집 → (REST API · Dashboard) 순서로 가져오고 배포하십시오. 순서가 거꾸로면 값이 비어 있을 뿐 오류는 아닙니다 — 앞 탭이 한 바퀴 돌면 채워집니다. 글로벌 컨텍스트는 메모리에만 있어 Node-RED 를 다시 시작하면 비워집니다.
2. 가져오는 법
- 좌측 메뉴 플로우 (Node-RED) 로 편집 화면을 엽니다. (플로우 시작하기)
- 이 페이지에서 가져올 탭의 JSON 보기 를 펼치고, 코드 상자의 복사 버튼을 누릅니다. (또는 위 표의 파일을 내려받아 내용을 복사합니다.)
- Node-RED 우상단 메뉴(☰) → 가져오기 를 엽니다.
- 클립보드 탭의 입력 칸에 붙여 넣습니다. (파일을 쓰려면 선택한 파일 가져오기 도 됩니다.)
- 가져오기 대상 은 새 플로우 로 둡니다. 현재 탭에 섞이지 않고 탭이 하나 새로 생깁니다.
- 가져오기 를 누르고, 아래 3절의 값을 바꾼 뒤 우상단 배포 를 누릅니다.
- 우측 사이드바의 debug 탭에서 결과를 확인합니다.
3. 가져온 뒤 바꿀 값
예제의 비밀값은 자리표시자 로 비워 두었습니다. 실제 값은 직접 넣으십시오.
| 자리표시자 | 어디에 있나 | 무엇으로 바꾸나 |
|---|---|---|
<EDGE_REST_API_KEY> | PlantPulse 노드 검증 탭의 태그값 쓰기 · 태그값 읽기 노드 (더블클릭 → API Key 칸). 세 곳 | 게이트웨이 app.properties 의 edge.rest.api.key 값. 어디서 정하고 어떻게 넣는지는 REST API 인증 과 인증 정보 를 보십시오 |
<USER_ID> · <PASSWORD> | Edge 시스템 모니터링 탭의 로그인 준비 function 노드 (코드 안 한 줄) | 게이트웨이 웹 UI 에 로그인하는 계정 |
API Key 와 비밀번호는 flows.json 에 그대로 저장되고, 플로우를 내보내기 하면 JSON 에도 실립니다. 값을 넣은 플로우를 메신저·이슈·문서에 붙여 넣지 마십시오. 이 페이지의 JSON 에는 값이 없습니다.
로그인에는 무차별 대입 방어가 걸려 있습니다. 같은 접속 IP 와 같은 사용자 ID 로 5번 실패하면 5분 동안 잠깁니다. 시스템 모니터링 탭은 30초마다 로그인하므로, 틀린 비밀번호를 넣은 채 배포하면 2분 30초 만에 잠깁니다. Node-RED 는 엣지 자신(127.0.0.1)에서 로그인하므로 잠기는 것은 «127.0.0.1 + 그 ID» 조합뿐이고, 다른 PC 에서 같은 ID 로 들어오는 것은 막히지 않습니다. 값을 확인한 뒤 배포하고, 틀렸다면 곧바로 탭을 비활성화하십시오.
4. 탭별 설명
PlantPulse 노드 검증
목적 — PlantPulse Edge 노드 카테고리의 태그값 쓰기 · 태그값 읽기 노드가 동작하는지 확인합니다. 노드 설명은 엣지 전용 노드 를 보십시오.
구성
| 줄 | 흐름 |
|---|---|
| 쓰기 후 확인 | 쓰기 trigger (4242) → 태그값 쓰기 → 쓰기 결과 (debug) 와 쓰기 후 readback (태그값 읽기, Fresh 켬) → readback 결과 |
| 단순 읽기 | 단순 읽기 trigger → 태그값 읽기 (캐시) → 캐시 read 결과 |
필요한 것 — TAG_TEST_00042 라는 태그가 있어야 하고, 쓰기가 가능한 태그여야 합니다. 다른 태그를 쓰려면 두 inject 노드의 tag_id 값을 바꾸십시오. 3절의 API Key 도 넣습니다.
쓰기 trigger (4242) 를 누르면 그 태그에 4242 가 써집니다. 실설비 태그가 아닌 테스트 태그·시뮬레이터 태그에서만 누르십시오.
보이는 것 — inject 왼쪽 버튼을 누르면 debug 사이드바에 결과가 찍힙니다. 쓰기 결과에는 result: "SUCCESS" 와 드라이버가 쓴 직후 다시 읽은 값(actual_read)이, readback 결과에는 value: "4242" 가 보여야 합니다. 노드 아래의 상태 문구가 401 unauthorized — check api_key 이면 API Key 가 틀렸거나 비어 있는 것입니다.
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.server.topic, 기본 /edge/point)을 구독해, 태그와 값을 꺼내 보여 주고 값이 100 을 넘으면 경보를 띄웁니다. 같은 값을 WebSocket(/ws/edge)으로도 내보내 외부 화면이 구독할 수 있습니다.
구성 — /edge/point 구독 (mqtt in, QoS 1) → 태그/값 추출 → 수신 데이터 (debug) / 임계값 초과? (값 > 100) → 임계값 초과 경보 (debug) / WebSocket /ws/edge.
필요한 것 — MQTT 발행이 켜져 있어야 합니다(mqtt.enable). 내장 브로커는 익명 접속을 받지 않습니다. 가져온 뒤 반드시 Edge MQTT (127.0.0.1:1883) 브로커 설정의 보안 탭에 엣지 app.properties 의 mqtt.server.user · mqtt.server.password 를 넣으십시오 — MQTT 노드 를 보십시오. 임계값 100 은 예시이니 임계값 초과? 노드에서 바꾸십시오.
보이는 것 — debug 사이드바에 { tag, value, timestamp } 모양이 태그마다 한 건씩 찍히고, 노드 아래 상태에 마지막 값이 뜹니다. 구독 노드가 connected (초록)가 아니면 브로커 주소·인증을 확인하십시오.
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 시스템 모니터링
목적 — 웹 UI 에 로그인한 세션으로 /monitor/bean 을 불러 시스템 지표를 얻습니다. 이 탭은 다른 탭이 쓰는 세션(jsession)과 지표(metrics_cache)의 공급원입니다.
구성 — 30초 주기 (배포 1초 뒤 한 번 즉시 실행) → 로그인 준비 → POST /login/login → 쿠키 추출 → 모니터 요청 → GET /monitor/bean → 메트릭 추출 → 시스템 메트릭 (debug).
필요한 것 — 3절의 <USER_ID> · <PASSWORD>.
보이는 것 — debug 에 CPU·메모리·디스크 사용률, 태그 수, OPC 수, MPS(초당 메시지), PLC 연결/끊김 수, 온도, 네트워크 IP 가 한 객체로 찍힙니다. 로그인에 실패하면 로그인 실패 경고가 나오고 이후 단계는 멈춥니다.
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 데이터 수집
목적 — 5초마다 등록된 모든 OPC 의 태그 목록과 현재값을 모아 하나의 배열(tags_cache)로 만들고, WebSocket(/ws/edge)으로 밀어 줍니다.
구성 — 5초 태그 조회 → OPC 목록 조회 준비 → OPC 목록 조회 → OPC별 태그 조회 분기 (OPC 마다 한 건씩) → 태그 목록 조회 → 태그 수집 및 합산 → 전체 태그 현재값 (debug) / WebSocket push /ws/edge.
필요한 것 — 시스템 모니터링 탭이 먼저 로그인해 있어야 합니다. 세션이 없으면 세션 없음 - 모니터 탭 로그인 대기중 경고만 나오고 건너뜁니다. 세션이 만료되면 스스로 비우고, 시스템 모니터링 탭이 다음 주기(30초)에 새로 로그인합니다.
보이는 것 — 전체 태그 N개 수집 완료 가 5초마다 찍히고, 펼치면 태그별 id · opc_id · value · type · time · status · address 가 보입니다.
태그 수가 많은 박스에서는 5초마다 전 OPC 를 훑는 것이 부담이 됩니다. 수천 개 이상이면 주기를 늘리십시오(5초 태그 조회 의 반복 간격).
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
목적 — Node-RED 자체가 작은 HTTP 서버가 되어, 위 탭들이 모은 값을 외부에 내줍니다.
| 주소 | 내용 |
|---|---|
GET /tags | 수집된 전체 태그 (count, data, ts) |
GET /metrics | 시스템 지표 (data, ts) |
필요한 것 — 시스템 모니터링 · 데이터 수집 탭. 값이 아직 없으면 /tags 는 빈 배열, /metrics 는 빈 객체를 돌려줍니다.
보이는 것 — 브라우저나 curl -k https://<게이트웨이>:1880/tags 로 JSON 을 받습니다. Node-RED 는 1880 에서 HTTPS 로 응답합니다.
/tags · /metrics 는 로그인도 API Key 도 요구하지 않습니다. Node-RED 는 1880 을 엣지의 모든 네트워크 인터페이스에 열므로, 엣지에 닿는 사람은 누구나 읽습니다. 1880 을 방화벽으로 막아 둔 박스에서만 쓰고(네트워크 구성), 외부에 내줄 거라면 http in 앞에 검사를 추가하십시오.
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
목적 — 위 탭이 모은 값을 화면으로 봅니다. 세 개의 화면(탭)이 생깁니다.
| 화면 | 내용 |
|---|---|
| 시스템 모니터 | CPU · 메모리 · 디스크 게이지, 온도, 태그·OPC·MPS 요약 |
| OPC-UA 태그 | 태그 현재값 표 (태그 ID · 값 · 시간 · 상태) |
| 태그 모니터 | WebSocket(/ws/edge)으로 실시간 갱신되는 전체 화면 모니터. OPC·태그 필터, 라이트/다크 전환 |
필요한 것 — 게이트웨이 이미지에는 Dashboard 노드가 둘 다 들어 있어 따로 설치할 것이 없습니다. 이 예제는 그 중 1세대 node-red-dashboard (ui_* 노드) 로 만들었습니다 (Dashboard 2 가 아닙니다). 시스템 모니터링 · 데이터 수집 탭이 값을 채워야 화면에 값이 나옵니다. 팔레트에 dashboard 카테고리가 보이면 준비된 것입니다.
보는 곳 — https://<게이트웨이>:1880/ui. 자체 서명 인증서이면 브라우저 경고를 한 번 넘겨야 합니다. 편집 화면 우측 사이드바의 dashboard 탭에서 열기 아이콘 으로도 갑니다.
보이는 것 — 5초마다 게이지와 표가 갱신됩니다. 게이지가 0 에서 움직이지 않으면 시스템 모니터링 탭이 지표를 못 받고 있는 것입니다. 태그 모니터가 WebSocket 연결 대기 중... 에서 멈추면 데이터 수집 탭이 돌고 있는지 확인하십시오.
Dashboard 탭 JSON 에는 화면 구성(ui_tab · ui_group)과 전체 설정(ui_base, 사이트 이름 「Edge 모니터링」)이 함께 들어 있습니다. 이미 Dashboard 를 쓰고 있다면 5절의 충돌 안내를 먼저 읽으십시오.
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. 전부 한 번에 가져오기
위 여섯 탭을 한 번에 가져오려면 all-flows.json (약 43 KB)을 내려받아 선택한 파일 가져오기 로 가져옵니다. 크기가 커서 이 페이지에는 코드 상자를 두지 않았습니다. 3절의 자리표시자 네 곳은 똑같이 바꿔야 합니다.
6. 가져오기 충돌
이미 쓰고 있는 박스에 가져오면 이름이 겹칠 수 있습니다.
| 상황 | 화면 | 하는 일 |
|---|---|---|
| 같은 플로우를 다시 가져옴 | 가져오기 대화상자에 「이미 있는 노드」 경고가 뜨고 가져오기 대상 이 갈라짐 | 사본 가져오기 를 고르십시오. 기존 노드를 덮어쓰지 않고 새 ID 로 하나 더 만듭니다. 덮어쓰기(현재 노드 바꾸기)는 손으로 고친 설정을 잃으니 피하십시오 |
| MQTT 브로커 · WebSocket 리스너 설정 노드가 이미 있음 | 같은 경고 | 사본으로 가져오면 설정 노드가 새로 생깁니다. 기존 것을 쓰려면 가져온 노드에서 서버 항목을 기존 것으로 바꾸십시오 |
/ws/edge 가 두 번 열림 | 배포 때 경로 사용 중 류 오류 | Edge MQTT 수신 · Edge 데이터 수집 탭이 각자 /ws/edge 리스너를 하나씩 가집니다. 개별로 가져왔을 때 한쪽을 지우고 다른 쪽 리스너를 공유시키십시오. 전체 파일은 하나만 둡니다 |
/tags · /metrics 경로가 이미 있음 | 같은 경로의 http in 이 둘 | 한쪽을 지우거나 경로를 바꾸십시오 |
| Dashboard 설정이 이미 있음 | ui_base 가 둘 | 기존 설정을 유지하고 가져온 ui_base 는 가져오기 대화상자에서 건너뜁니다 |
7. 안 될 때
| 증상 | 확인 |
|---|---|
가져오기에서 알 수 없는 노드 경고 | 노드 유형 이름을 보십시오. pp-* 이면 PlantPulse Edge 노드 카테고리가 팔레트에 있는지, ui_* 이면 dashboard 카테고리가 있는지 확인합니다. 이미지에는 둘 다 기본 포함이니, 없으면 Node-RED 로그(플로우 문제 해결)를 보십시오 |
쓰기·읽기 노드가 401 unauthorized | API Key 가 비었거나 틀림 — 3절 |
쓰기·읽기 노드가 tag_id empty | inject 의 tag_id 를 비웠거나 노드 속성에 태그를 지정하지 않음 |
시스템 모니터링에서 로그인 실패 | 계정 오류, 또는 잠김(3절) |
데이터 수집이 세션 없음 | 시스템 모니터링 탭이 배포돼 있고 로그인에 성공했는지 |
| 값은 오는데 태그 모니터 화면이 비어 있음 | 브라우저 개발자 도구에서 wss://…:1880/ws/edge 연결 오류 확인 |