[
  {
    "id": "pp-test-tab",
    "type": "tab",
    "label": "PlantPulse 노드 검증",
    "disabled": false,
    "info": "엣지 카테고리의 [태그값 읽기] [태그값 쓰기] 노드 자동 검증 플로우.\n각 inject → node → debug 로 확인."
  },
  {
    "id": "tab_edge_mqtt",
    "type": "tab",
    "label": "Edge MQTT 수신",
    "disabled": false,
    "info": "",
    "env": []
  },
  {
    "id": "tab_edge_monitor",
    "type": "tab",
    "label": "Edge 시스템 모니터링",
    "disabled": false,
    "info": "",
    "env": []
  },
  {
    "id": "tab_opcua_read",
    "type": "tab",
    "label": "Edge 데이터 수집",
    "disabled": false,
    "info": "",
    "env": []
  },
  {
    "id": "7ea01498f8d0e9f5",
    "type": "tab",
    "label": "REST API",
    "disabled": false,
    "info": "",
    "env": []
  },
  {
    "id": "063f54dba22818e8",
    "type": "tab",
    "label": "Dashboard",
    "disabled": false,
    "info": "",
    "env": []
  },
  {
    "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": "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
      }
    }
  },
  {
    "id": "19b7f41773b6f5d3",
    "type": "ui_tab",
    "name": "시스템 모니터",
    "icon": "dashboard",
    "disabled": false,
    "hidden": false
  },
  {
    "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": "06645bd4e34ae6fb",
    "type": "ui_tab",
    "name": "OPC-UA 태그",
    "icon": "label",
    "disabled": false,
    "hidden": false
  },
  {
    "id": "a68ba43d50233268",
    "type": "ui_group",
    "name": "태그 현재값",
    "tab": "06645bd4e34ae6fb",
    "order": 1,
    "disp": true,
    "width": "12",
    "collapse": false
  },
  {
    "id": "cfg_ws_listener",
    "type": "websocket-listener",
    "path": "/ws/edge",
    "wholemsg": "false"
  },
  {
    "id": "tab_tag_monitor",
    "type": "ui_tab",
    "name": "태그 모니터",
    "icon": "dashboard",
    "order": 3,
    "disabled": false,
    "hidden": false
  },
  {
    "id": "grp_tag_monitor",
    "type": "ui_group",
    "name": "실시간 태그",
    "tab": "tab_tag_monitor",
    "order": 1,
    "disp": false,
    "width": "12",
    "collapse": false
  },
  {
    "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": []
  },
  {
    "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": "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": []
  },
  {
    "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": "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": []
  },
  {
    "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": [
      []
    ]
  }
]
