インポート用例題フロー
ゲートウェイの Node-RED は 空のキャンバスで出荷されます。 標準インストール版には例題フローを入れていません — 最初の画面をすっきりさせておくための判断です。その代わり、このページに すぐにインポートして動かせる例題を 6 つ 用意しています。JSON をコピーして Node-RED に貼り付けるとタブができ、デプロイ を押すと動きます。
これらの例題はゲートウェイ 1 台の中で完結します。アドレスはすべて 127.0.0.1 (ゲートウェイ自身)で、MQTT 1883 はゲートウェイ内蔵ブローカーです。フローを ゲートウェイ内の Node-RED にインポートすれば、アドレスを直す必要はありません。
例題集 はパターンを文章とコード断片で説明します。このページの例題は インポートするだけでノードがすでに接続されている完成フロー です。
1. 一覧
| タブ名 | 役割 | 事前に必要なもの | ダウンロード |
|---|---|---|---|
| PlantPulse 노드 검증(PlantPulse ノード検証) | タグ値書き込み・タグ値読み取りノードが動作するかを確認 | REST API キー、書き込み可能なテストタグ | plantpulse-node-check.json |
| Edge MQTT 수신(Edge MQTT 受信) | 内蔵ブローカーのタグ発行(/edge/point)を受け取り、しきい値を検査して WebSocket で送出 | MQTT 発行が有効 | edge-mqtt-receive.json |
| Edge 시스템 모니터링(Edge システム監視) | 30 秒ごとにログインし、CPU・メモリ・ディスクなどのシステム指標を読む | Web UI のログインアカウント | edge-system-monitoring.json |
| Edge 데이터 수집(Edge データ収集) | 5 秒ごとにすべての OPC のタグ現在値を集め、1 つにまとめて保持・プッシュ | システム監視タブが先に動いていること | edge-data-collection.json |
| REST API | 上で集めたタグ・指標を /tags · /metrics で提供 | システム監視 + データ収集タブ | rest-api.json |
| Dashboard | ゲージ・表・リアルタイムモニター画面 | システム監視 + データ収集タブ | dashboard.json |
| 上記 6 タブすべて | 一度にインポート | 上記の条件すべて | 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 右上の メニュー(☰) → 読み込み を開きます。
- クリップボード タブの入力欄に 貼り付けます。 (ファイルを使う場合は 読み込むファイルを選択 でも構いません。)
- 読み込み先 は 新規のフロー のままにします。現在のタブに混ざらず、タブが 1 つ新しくできます。
- 読み込み を押し、下記 3 節の値を変更してから右上の デプロイ を押します。
- 右サイドバーの debug タブで結果を確認します。
3. インポート後に変更する値
例題の秘密値は プレースホルダー で空けてあります。実際の値はご自身で入力してください。
| プレースホルダー | どこにあるか | 何に置き換えるか |
|---|---|---|
<EDGE_REST_API_KEY> | PlantPulse ノード検証タブの タグ値書き込み · タグ値読み取り ノード (ダブルクリック → API Key 欄)。3 か所 | ゲートウェイ app.properties の edge.rest.api.key の値。どこで決めてどう入れるかは REST API 認証 と 認証情報 をご覧ください |
<USER_ID> · <PASSWORD> | Edge システム監視タブの 로그인 준비(ログイン準備) function ノード (コード内の 1 行) | ゲートウェイ Web 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 というタグが存在し、かつ 書き込み可能なタグ である必要があります。別のタグを使うには、2 つの 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 } の形がタグごとに 1 件ずつ表示され、ノード下のステータスに最後の値が出ます。購読ノードが 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 システム監視
目的 — Web UI にログインしたセッションで /monitor/bean を呼び出し、システム指標を取得します。このタブは、他のタブが使う セッション(jsession)と指標(metrics_cache)の供給元 です。
構成 — 30초 주기 (デプロイの 1 秒後に 1 回即時実行) → 로그인 준비 → POST /login/login → 쿠키 추출 → 모니터 요청 → GET /monitor/bean → 메트릭 추출 → 시스템 메트릭 (debug)。
必要なもの — 3 節の <USER_ID> · <PASSWORD>。
見えるもの — debug に CPU・メモリ・ディスク使用率、タグ数、OPC 数、MPS(毎秒メッセージ数)、PLC 接続/切断数、温度、ネットワーク IP が 1 つのオブジェクトとして表示されます。ログインに失敗すると 로그인 실패(ログイン失敗)の警告が出て、以降の段階は止まります。
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 のタグ一覧と現在値を集めて 1 つの配列(tags_cache)にし、WebSocket(/ws/edge)でプッシュします。
構成 — 5초 태그 조회 → OPC 목록 조회 준비 → OPC 목록 조회 → OPC별 태그 조회 분기 (OPC ごとに 1 件ずつ) → 태그 목록 조회 → 태그 수집 및 합산 → 전체 태그 현재값 (debug) / WebSocket push /ws/edge。
必要なもの — システム監視タブが先にログインしている必要があります。 セッションがなければ 세션 없음 - 모니터 탭 로그인 대기중(セッションなし - 監視タブのログイン待ち)の警告だけが出てスキップされます。セッションが期限切れになると自ら空にし、システム監視タブが次の周期(30 秒)に新たにログインします。
見えるもの — 전체 태그 N개 수집 완료(全タグ 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
目的 — 上のタブが集めた値を画面で見ます。3 つの画面(タブ)ができます。
| 画面 | 内容 |
|---|---|
| 시스템 모니터(システムモニター) | CPU · メモリ · ディスクのゲージ、温度、タグ・OPC・MPS の要約 |
| OPC-UA 태그(OPC-UA タグ) | タグ現在値の表 (タグ ID · 値 · 時刻 · 状態) |
| 태그 모니터(タグモニター) | WebSocket(/ws/edge)でリアルタイム更新される全画面モニター。OPC・タグのフィルター、ライト/ダークの切り替え |
必要なもの — ゲートウェイイメージには Dashboard ノードが両方入っているので、別途インストールするものはありません。この例題はそのうち 第 1 世代の node-red-dashboard (ui_* ノード) で作りました (Dashboard 2 ではありません)。システム監視 · データ収集タブが値を埋めないと画面に値は出ません。パレットに dashboard カテゴリが見えれば準備完了です。
見る場所 — https://<게이트웨이>:1880/ui。自己署名証明書の場合は、ブラウザーの警告を一度通過する必要があります。編集画面の右サイドバーの dashboard タブにある 開くアイコン からも行けます。
見えるもの — 5 秒ごとにゲージと表が更新されます。ゲージが 0 から動かなければ、システム監視タブが指標を受け取れていません。タグモニターが WebSocket 연결 대기 중...(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. すべてを一度にインポート
上記 6 タブを一度にインポートするには、all-flows.json (約 43 KB)をダウンロードし、読み込むファイルを選択 でインポートします。サイズが大きいため、このページにはコードボックスを置いていません。3 節のプレースホルダー 4 か所は同様に置き換える必要があります。
6. インポート時の衝突
すでに使用中のボックスにインポートすると、名前が重なることがあります。
| 状況 | 画面 | 対処 |
|---|---|---|
| 同じフローを再度インポート | 読み込みダイアログに 「既存のノード」 の警告が出て、読み込み先 が分かれる | コピーを読み込み を選んでください。既存のノードを上書きせず、新しい ID でもう 1 つ作ります。上書き(既存のノードを置換)は手で直した設定を失うので避けてください |
| MQTT ブローカー · WebSocket リスナーの設定ノードがすでにある | 同じ警告 | コピーとしてインポートすると設定ノードが新しくできます。既存のものを使うには、インポートしたノードでサーバー項目を既存のものに変更してください |
/ws/edge が 2 回開かれる | デプロイ時に 경로 사용 중(パス使用中)類のエラー | Edge MQTT 受信 · Edge データ収集タブがそれぞれ /ws/edge リスナーを 1 つずつ持っています。個別にインポートした場合は片方を削除し、もう片方のリスナーを共有させてください。全体ファイルでは 1 つだけです |
/tags · /metrics のパスがすでにある | 同じパスの http in が 2 つ | 片方を削除するか、パスを変更してください |
| Dashboard 設定がすでにある | ui_base が 2 つ | 既存の設定を維持し、インポートした 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 の接続エラーを確認 |
次のステップ
- 例題集 — パターン別の説明
- エッジ専用ノード — タグ読み取り・書き込みノードの全フィールド
- フローのトラブルシューティング