본문으로 건너뛰기

가져오기용 예제 플로우

게이트웨이의 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. 가져오는 법​

  1. 좌측 메뉴 플로우 (Node-RED) 로 편집 화면을 엽니다. (플로우 시작하기)
  2. 이 페이지에서 가져올 탭의 JSON 보기 를 펼치고, 코드 상자의 복사 버튼을 누릅니다. (또는 위 표의 파일을 내려받아 내용을 복사합니다.)
  3. Node-RED 우상단 메뉴(☰) → 가져오기 를 엽니다.
  4. 클립보드 탭의 입력 칸에 붙여 넣습니다. (파일을 쓰려면 선택한 파일 가져오기 도 됩니다.)
  5. 가져오기 대상 은 새 플로우 로 둡니다. 현재 탭에 섞이지 않고 탭이 하나 새로 생깁니다.
  6. 가져오기 를 누르고, 아래 3절의 값을 바꾼 뒤 우상단 배포 를 누릅니다.
  7. 우측 사이드바의 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 는 실제로 값을 씁니다

쓰기 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 unauthorizedAPI Key 가 비었거나 틀림 — 3절
쓰기·읽기 노드가 tag_id emptyinject 의 tag_id 를 비웠거나 노드 속성에 태그를 지정하지 않음
시스템 모니터링에서 로그인 실패계정 오류, 또는 잠김(3절)
데이터 수집이 세션 없음시스템 모니터링 탭이 배포돼 있고 로그인에 성공했는지
값은 오는데 태그 모니터 화면이 비어 있음브라우저 개발자 도구에서 wss://…:1880/ws/edge 연결 오류 확인

다음 단계​