From 3c44b4990468c6a73d5b3926f6e952ebc64e9f18 Mon Sep 17 00:00:00 2001 From: Ruokun Niu Date: Tue, 27 Aug 2024 15:23:56 -0700 Subject: [PATCH 1/8] configured continuous queries --- apps/building-comfort/.DS_Store | Bin 6148 -> 0 bytes apps/building-comfort/app/package.json | 2 +- apps/building-comfort/app/src/App.js | 4 +- .../devops/reactive-graph/query-alert.yaml | 54 +++++-- .../reactive-graph/query-comfort-calc.yaml | 23 ++- .../devops/reactive-graph/query-ui.yaml | 7 +- .../functions/building/function.json | 19 --- .../functions/building/index.js | 136 ----------------- .../functions/floor/function.json | 19 --- .../building-comfort/functions/floor/index.js | 114 --------------- .../functions/room/function.json | 19 --- apps/building-comfort/functions/room/index.js | 90 ------------ .../functions/sensor/index.js | 8 +- apps/react/connection-pool.js | 17 +++ apps/react/index.js | 4 + apps/react/package.json | 15 ++ apps/react/reaction-listener.js | 50 +++++++ apps/react/reaction-result.js | 137 ++++++++++++++++++ apps/react/readme.md | 42 ++++++ 19 files changed, 343 insertions(+), 417 deletions(-) delete mode 100644 apps/building-comfort/.DS_Store delete mode 100644 apps/building-comfort/functions/building/function.json delete mode 100644 apps/building-comfort/functions/building/index.js delete mode 100644 apps/building-comfort/functions/floor/function.json delete mode 100644 apps/building-comfort/functions/floor/index.js delete mode 100644 apps/building-comfort/functions/room/function.json delete mode 100644 apps/building-comfort/functions/room/index.js create mode 100644 apps/react/connection-pool.js create mode 100644 apps/react/index.js create mode 100644 apps/react/package.json create mode 100644 apps/react/reaction-listener.js create mode 100644 apps/react/reaction-result.js create mode 100644 apps/react/readme.md diff --git a/apps/building-comfort/.DS_Store b/apps/building-comfort/.DS_Store deleted file mode 100644 index fd5a0a2a38134c3b0b5dabe762aecaad25caef5a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKL2uJA6n<{YmS{pM5=d}BinQw}Edp`i63RGmB?u0HO0tv@mf2O4u7{~o?(knY z^GEnEoZx%*+q7hGMFRXJ`}2E#pB;aW;+Tlkbd^tthC~#gF!n-JzcB9Sa>;rU*#;^e z!yII#$>8yD-u?y!c<=g@(t;Mx%GUlpM$hPnHM>!ro8;6L%{EvW@ L!B<{^KdQh_4AqM> diff --git a/apps/building-comfort/app/package.json b/apps/building-comfort/app/package.json index 06d9e9a..4d18b44 100644 --- a/apps/building-comfort/app/package.json +++ b/apps/building-comfort/app/package.json @@ -14,7 +14,7 @@ "react": "^18.2.0", "react-dom": "^18.2.0", "react-gauge-chart": "^0.4.0", - "react-reactive-graph": "file:../../../src/clients/react", + "react-reactive-graph": "file:../../react", "react-scripts": "5.0.1", "rxjs": "^7.5.6", "uuid": "^8.3.2", diff --git a/apps/building-comfort/app/src/App.js b/apps/building-comfort/app/src/App.js index 91acd0e..67de1d1 100644 --- a/apps/building-comfort/app/src/App.js +++ b/apps/building-comfort/app/src/App.js @@ -71,7 +71,6 @@ function initFloorQuery() { if (change.op === 'x') { return; } - floorStats.set(change.payload.after.FloorId, change.payload.after); floorSubject.next(change.payload.after); }); @@ -192,10 +191,9 @@ function Building(props) { function Floor(props) { const [floorComfort, setFloorComfort] = React.useState(floorStats.has(props.floor.id) ? floorStats.get(props.floor.id) : {}); - React.useEffect(() => { let subscription = floorSubject.subscribe(v => { - if (v.FloorId == props.floor.id) + if (v.FloorId === props.floor.id) setFloorComfort(v); }); diff --git a/apps/building-comfort/devops/reactive-graph/query-alert.yaml b/apps/building-comfort/devops/reactive-graph/query-alert.yaml index 3f67557..2b28301 100644 --- a/apps/building-comfort/devops/reactive-graph/query-alert.yaml +++ b/apps/building-comfort/devops/reactive-graph/query-alert.yaml @@ -1,6 +1,9 @@ +# Calculates the comfort level of rooms. +# Retrieves all rooms that have a comfort level below 40 or above 50. +# Returns the room ID, room name, and comfort level of each room. kind: ContinuousQuery apiVersion: v1 -name: building-alert +name: room-alert spec: mode: query sources: @@ -8,11 +11,16 @@ spec: - id: facilities query: > MATCH - (b:Building) - WHERE b.comfortLevel < 40 OR b.comfortLevel > 50 + (r:Room) + WITH + elementId(r) AS RoomId, + r.name AS RoomName, + floor( 50 + (r.temp - 72) + (r.humidity - 42) + CASE WHEN r.co2 > 500 THEN (r.co2 - 500) / 25 ELSE 0 END ) AS ComfortLevel + WHERE ComfortLevel < 40 OR ComfortLevel > 50 RETURN - elementId(b) AS BuildingId, b.name AS BuildingName, b.comfortLevel AS ComfortLevel + RoomId, RoomName, ComfortLevel --- +# Calculates the comfort level of floors kind: ContinuousQuery apiVersion: v1 name: floor-alert @@ -23,14 +31,23 @@ spec: - id: facilities query: > MATCH - (f:Floor) - WHERE f.comfortLevel < 40 OR f.comfortLevel > 50 + (r:Room)-[:PART_OF]->(f:Floor) + WITH + f, + floor( 50 + (r.temp - 72) + (r.humidity - 42) + CASE WHEN r.co2 > 500 THEN (r.co2 - 500) / 25 ELSE 0 END ) AS RoomComfortLevel + WITH + f, + avg(RoomComfortLevel) AS ComfortLevel + WHERE + ComfortLevel < 40 OR ComfortLevel > 50 RETURN - elementId(f) AS FloorId, elementId(f) AS FloorName, f.comfortLevel AS ComfortLevel + elementId(f) AS FloorId, + f.name AS FloorName, + ComfortLevel --- kind: ContinuousQuery apiVersion: v1 -name: room-alert +name: building-alert spec: mode: query sources: @@ -38,7 +55,22 @@ spec: - id: facilities query: > MATCH - (r:Room) - WHERE r.comfortLevel < 40 OR r.comfortLevel > 50 + (r:Room)-[:PART_OF]->(f:Floor)-[:PART_OF]->(b:Building) + WITH + f, + b, + floor( 50 + (r.temp - 72) + (r.humidity - 42) + CASE WHEN r.co2 > 500 THEN (r.co2 - 500) / 25 ELSE 0 END ) AS RoomComfortLevel + WITH + f, + b, + avg(RoomComfortLevel) AS FloorComfortLevel + WITH + b, + avg(FloorComfortLevel) AS ComfortLevel + WHERE + ComfortLevel < 40 OR ComfortLevel > 50 RETURN - elementId(r) AS RoomId, r.name AS RoomName, r.comfortLevel AS ComfortLevel \ No newline at end of file + elementId(b) AS BuildingId, + b.name AS BuildingName, + ComfortLevel + \ No newline at end of file diff --git a/apps/building-comfort/devops/reactive-graph/query-comfort-calc.yaml b/apps/building-comfort/devops/reactive-graph/query-comfort-calc.yaml index e092008..bf1630e 100644 --- a/apps/building-comfort/devops/reactive-graph/query-comfort-calc.yaml +++ b/apps/building-comfort/devops/reactive-graph/query-comfort-calc.yaml @@ -8,9 +8,19 @@ spec: - id: facilities query: > MATCH - (f:Floor)-[:PART_OF]->(b:Building) + (r:Room)-[:PART_OF]->(f:Floor)-[:PART_OF]->(b:Building) + WITH + b, + floor( 50 + (r.temp - 72) + (r.humidity - 42) + CASE WHEN r.co2 > 500 THEN (r.co2 - 500) / 25 ELSE 0 END ) AS RoomComfortLevel + WITH + b, + avg(RoomComfortLevel) AS FloorComfortLevel + WITH + b, + avg(FloorComfortLevel) AS ComfortLevel RETURN - elementId(b) AS BuildingId, avg(f.comfortLevel) AS ComfortLevel + elementId(b) AS BuildingId, + ComfortLevel --- kind: ContinuousQuery apiVersion: v1 @@ -23,8 +33,15 @@ spec: query: > MATCH (r:Room)-[:PART_OF]->(f:Floor) + WITH + f, + floor( 50 + (r.temp - 72) + (r.humidity - 42) + CASE WHEN r.co2 > 500 THEN (r.co2 - 500) / 25 ELSE 0 END ) AS RoomComfortLevel + WITH + f, + avg(RoomComfortLevel) AS ComfortLevel RETURN - elementId(f) AS FloorId, avg(r.comfortLevel) AS ComfortLevel + elementId(f) AS FloorId, + ComfortLevel --- kind: ContinuousQuery apiVersion: v1 diff --git a/apps/building-comfort/devops/reactive-graph/query-ui.yaml b/apps/building-comfort/devops/reactive-graph/query-ui.yaml index 4fca424..ba60d66 100644 --- a/apps/building-comfort/devops/reactive-graph/query-ui.yaml +++ b/apps/building-comfort/devops/reactive-graph/query-ui.yaml @@ -9,6 +9,11 @@ spec: query: > MATCH (r:Room)-[:PART_OF]->(f:Floor)-[:PART_OF]->(b:Building) + WITH + r, + f, + b, + floor( 50 + (r.temp - 72) + (r.humidity - 42) + CASE WHEN r.co2 > 500 THEN (r.co2 - 500) / 25 ELSE 0 END ) AS ComfortLevel RETURN elementId(r) AS RoomId, r.name AS RoomName, @@ -19,4 +24,4 @@ spec: r.temp AS Temperature, r.humidity AS Humidity, r.co2 AS CO2, - r.comfortLevel AS ComfortLevel + ComfortLevel diff --git a/apps/building-comfort/functions/building/function.json b/apps/building-comfort/functions/building/function.json deleted file mode 100644 index 6ed8bd2..0000000 --- a/apps/building-comfort/functions/building/function.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "bindings": [ - { - "authLevel": "anonymous", - "type": "httpTrigger", - "direction": "in", - "name": "req", - "methods": [ - "get" - ], - "route": "building/{bid?}" - }, - { - "type": "http", - "direction": "out", - "name": "$return" - } - ] -} \ No newline at end of file diff --git a/apps/building-comfort/functions/building/index.js b/apps/building-comfort/functions/building/index.js deleted file mode 100644 index 2b190ea..0000000 --- a/apps/building-comfort/functions/building/index.js +++ /dev/null @@ -1,136 +0,0 @@ -const gremlin = require('gremlin'); - -// Facilities Client -const authenticator = new gremlin.driver.auth.PlainTextSaslAuthenticator( - `/dbs/${process.env["FACILITIES_DB_NAME"]}/colls/${process.env["FACILITIES_CNT_NAME"]}`, process.env["FACILITIES_KEY"] -) - -const client = new gremlin.driver.Client( - process.env["FACILITIES_URL"], - { - authenticator, - traversalsource: "g", - rejectUnauthorized: true, - mimeType: "application/vnd.gremlin-v2.0+json" - } -); - -async function GetBuildingById(context, id, includeFloors = false, includeRooms = false) { - // context.log(`GetBuildingById: ${JSON.stringify(id)}`); - - const res = await client.submit(`g.V(id).hasLabel("Building")`, { id }); - const node = res.first(); - - if (node) { - return { - body: { - id: node.id, - name: node.properties?.name[0]?.value ?? "", - comfortLevel: node.properties?.comfortLevel[0]?.value ?? "", - floors: includeFloors ? await GetAllFloorsForBuilding(context, id, includeRooms) : undefined - } - }; - } else { - // TODO - } -} - -async function GetAllBuildings(context, includeFloors = false, includeRooms = false) { - // context.log(`GetAllBuildings`); - - const buildings = []; - var readable = client.stream(`g.V().hasLabel("Building")`, {}, { batchSize: 100 }); - - try { - for await (const result of readable) { - for (const node of result.toArray()) { - const v = { - id: node.id, - name: node.properties?.name[0]?.value ?? "", - comfortLevel: node.properties?.comfortLevel[0]?.value ?? "", - floors: includeFloors ? await GetAllFloorsForBuilding(context, node.id, includeRooms) : undefined - }; - buildings.push(v); - } - } - } catch (err) { - console.error(err.stack); - } - - return { - body: buildings - }; -} - -async function GetAllFloorsForBuilding(context, bid, includeRooms = false) { - // context.log(`GetAllFloorsForBuilding`); - - const floors = []; - var readable = client.stream(`g.V(bid).hasLabel("Building").in("PART_OF").hasLabel("Floor")`, { bid }, { batchSize: 100 }); - - try { - for await (const result of readable) { - for (const node of result.toArray()) { - const v = { - id: node.id, - name: node.properties?.name[0]?.value ?? "", - comfortLevel: node.properties?.comfortLevel[0]?.value ?? "", - rooms: includeRooms ? await GetAllRoomsForFloor(context, node.id) : undefined - }; - floors.push(v); - } - } - } catch (err) { - console.error(err.stack); - } - - return floors; -} - -async function GetAllRoomsForFloor(context, fid) { - // context.log(`GetAllRoomsForFloor`); - - const rooms = []; - var readable = client.stream(`g.V(fid).hasLabel("Floor").in("PART_OF").hasLabel("Room")`, { fid }, { batchSize: 100 }); - - try { - for await (const result of readable) { - for (const node of result.toArray()) { - const v = { - id: node.id, - name: node.properties?.name[0]?.value ?? "", - temp: node.properties?.temp[0]?.value ?? "", - humidity: node.properties?.humidity[0]?.value ?? "", - co2: node.properties?.co2[0]?.value ?? "", - comfortLevel: node.properties?.comfortLevel[0]?.value ?? "" - }; - rooms.push(v); - } - } - } catch (err) { - console.error(err.stack); - } - - return rooms; -} - -module.exports = async function (context, req) { - // context.log(`request: ${JSON.stringify(req)}`); - - var result = {}; - - switch (req.method) { - case "GET": - const includeFloors = "includeFloors" in req.query && req.query.includeFloors != "false"; - const includeRooms = "includeRooms" in req.query && req.query.includeRooms != "false"; - if (req.params.bid) { - result = await GetBuildingById(context, req.params.bid, includeFloors, includeRooms); - } else { - result = await GetAllBuildings(context, includeFloors, includeRooms); - } - break; - default: - break; - } - return result; -} \ No newline at end of file diff --git a/apps/building-comfort/functions/floor/function.json b/apps/building-comfort/functions/floor/function.json deleted file mode 100644 index f566157..0000000 --- a/apps/building-comfort/functions/floor/function.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "bindings": [ - { - "authLevel": "anonymous", - "type": "httpTrigger", - "direction": "in", - "name": "req", - "methods": [ - "get" - ], - "route": "building/{bid}/floor/{fid?}" - }, - { - "type": "http", - "direction": "out", - "name": "$return" - } - ] -} \ No newline at end of file diff --git a/apps/building-comfort/functions/floor/index.js b/apps/building-comfort/functions/floor/index.js deleted file mode 100644 index 916aaea..0000000 --- a/apps/building-comfort/functions/floor/index.js +++ /dev/null @@ -1,114 +0,0 @@ -const gremlin = require('gremlin'); - -// Facilities Client -const authenticator = new gremlin.driver.auth.PlainTextSaslAuthenticator( - `/dbs/${process.env["FACILITIES_DB_NAME"]}/colls/${process.env["FACILITIES_CNT_NAME"]}`, process.env["FACILITIES_KEY"] -) - -const client = new gremlin.driver.Client( - process.env["FACILITIES_URL"], - { - authenticator, - traversalsource: "g", - rejectUnauthorized: true, - mimeType: "application/vnd.gremlin-v2.0+json" - } -); - -async function GetFloorById(context, bid, fid, includeRooms = false) { - // context.log(`GetFloorById: ${JSON.stringify(id)}`); - - const res = await client.submit(`g.V(bid).hasLabel("Building").in("PART_OF").hasLabel("Floor").hasId(fid)`, { bid, fid }); - const node = res.first(); - - if (node) { - return { - body: { - id: node.id, - buildingId: bid, - name: node.properties?.name[0]?.value ?? "", - comfortLevel: node.properties?.comfortLevel[0]?.value ?? "", - rooms: includeRooms ? await GetAllRoomsForFloor(context, bid, fid) : undefined - } - }; - } else { - // TODO - } -} - -async function GetAllFloors(context, bid, includeRooms = false) { - // context.log(`GetAllFloors`); - - const floors = []; - var readable = client.stream(`g.V(bid).hasLabel("Building").in("PART_OF").hasLabel("Floor")`, { bid }, { batchSize: 100 }); - - try { - for await (const result of readable) { - for (const node of result.toArray()) { - const v = { - id: node.id, - buildingId: bid, - name: node.properties?.name[0]?.value ?? "", - comfortLevel: node.properties?.comfortLevel[0]?.value ?? "", - rooms: includeRooms ? await GetAllRoomsForFloor(context, bid, node.id) : undefined - }; - floors.push(v); - } - } - } catch (err) { - console.error(err.stack); - } - - return { - body: floors - }; -} - - -async function GetAllRoomsForFloor(context, bid, fid) { - // context.log(`GetAllRoomsForFloor`); - - const rooms = []; - var readable = client.stream(`g.V(bid).hasLabel("Building").in("PART_OF").hasLabel("Floor").hasId(fid).in("PART_OF").hasLabel("Room")`, { bid, fid }, { batchSize: 100 }); - - try { - for await (const result of readable) { - for (const node of result.toArray()) { - const v = { - id: node.id, - floorId: fid, - name: node.properties?.name[0]?.value ?? "", - temp: node.properties?.temp[0]?.value ?? "", - humidity: node.properties?.humidity[0]?.value ?? "", - co2: node.properties?.co2[0]?.value ?? "", - comfortLevel: node.properties?.comfortLevel[0]?.value ?? "" - }; - rooms.push(v); - } - } - } catch (err) { - console.error(err.stack); - } - - return rooms; -} - -module.exports = async function (context, req) { - // context.log(`request: ${JSON.stringify(req)}`); - - var result = {}; - - switch (req.method) { - case "GET": - const includeRooms = "includeRooms" in req.query && req.query.includeRooms != "false"; - if (req.params.fid) { - result = await GetFloorById(context, req.params.bid, req.params.fid, includeRooms); - } else { - result = await GetAllFloors(context, req.params.bid, includeRooms); - } - break; - default: - break; - } - return result; -} \ No newline at end of file diff --git a/apps/building-comfort/functions/room/function.json b/apps/building-comfort/functions/room/function.json deleted file mode 100644 index 6a8a62e..0000000 --- a/apps/building-comfort/functions/room/function.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "bindings": [ - { - "authLevel": "anonymous", - "type": "httpTrigger", - "direction": "in", - "name": "req", - "methods": [ - "get" - ], - "route": "building/{bid}/floor/{fid}/room/{rid?}" - }, - { - "type": "http", - "direction": "out", - "name": "$return" - } - ] -} \ No newline at end of file diff --git a/apps/building-comfort/functions/room/index.js b/apps/building-comfort/functions/room/index.js deleted file mode 100644 index 6e9a9fa..0000000 --- a/apps/building-comfort/functions/room/index.js +++ /dev/null @@ -1,90 +0,0 @@ -const gremlin = require('gremlin'); - -// Facilities Client -const authenticator = new gremlin.driver.auth.PlainTextSaslAuthenticator( - `/dbs/${process.env["FACILITIES_DB_NAME"]}/colls/${process.env["FACILITIES_CNT_NAME"]}`, process.env["FACILITIES_KEY"] -) - -const client = new gremlin.driver.Client( - process.env["FACILITIES_URL"], - { - authenticator, - traversalsource: "g", - rejectUnauthorized: true, - mimeType: "application/vnd.gremlin-v2.0+json" - } -); - -async function GetRoomById(context, bid, fid, rid) { - // context.log(`GetRoomById: ${JSON.stringify(rid)}`); - - const res = await client.submit(`g.V(bid).hasLabel("Building").in("PART_OF").hasLabel("Floor").hasId(fid).in("PART_OF").hasLabel("Room").hasId(rid)`, { bid, fid, rid }); - const node = res.first(); - - if (node) { - return { - body: { - id: node.id, - buildingId: bid, - floorId: fid, - name: node.properties?.name[0]?.value ?? "", - temp: node.properties?.temp[0]?.value ?? "", - humidity: node.properties?.humidity[0]?.value ?? "", - co2: node.properties?.co2[0]?.value ?? "", - comfortLevel: node.properties?.comfortLevel[0]?.value ?? "" - } - }; - } else { - // TODO - } -} - -async function GetAllRooms(context, bid, fid) { - // context.log(`GetAllRooms`); - - const rooms = []; - var readable = client.stream(`g.V(bid).hasLabel("Building").in("PART_OF").hasLabel("Floor").hasId(fid).in("PART_OF").hasLabel("Room")`, { bid, fid }, { batchSize: 100 }); - - try { - for await (const result of readable) { - for (const node of result.toArray()) { - const v = { - id: node.id, - buildingId: bid, - floorId: fid, - name: node.properties?.name[0]?.value ?? "", - temp: node.properties?.temp[0]?.value ?? "", - humidity: node.properties?.humidity[0]?.value ?? "", - co2: node.properties?.co2[0]?.value ?? "", - comfortLevel: node.properties?.comfortLevel[0]?.value ?? "" - }; - rooms.push(v); - } - } - } catch (err) { - console.error(err.stack); - } - - return { - body: rooms - }; -} - -module.exports = async function (context, req) { - // context.log(`request: ${JSON.stringify(req)}`); - - var result = {}; - - switch (req.method) { - case "GET": - if (req.params.rid) { - result = await GetRoomById(context, req.params.bid, req.params.fid, req.params.rid); - } else { - result = await GetAllRooms(context, req.params.bid, req.params.fid); - } - break; - default: - break; - } - return result; -} \ No newline at end of file diff --git a/apps/building-comfort/functions/sensor/index.js b/apps/building-comfort/functions/sensor/index.js index c91ba9a..46fa948 100644 --- a/apps/building-comfort/functions/sensor/index.js +++ b/apps/building-comfort/functions/sensor/index.js @@ -36,6 +36,12 @@ async function UpdateSensor(context, bid, fid, rid, sid, sensorData) { // context.log(`UpdateOrder - res: ${JSON.stringify(res)}`); const node = res.first(); + let roomName = node.properties?.name[0]?.value ?? ""; + let temp = node.properties?.temp[0]?.value ?? ""; + let humidity = node.properties?.humidity[0]?.value ?? ""; + let co2 = node.properties?.co2[0]?.value ?? ""; + let comfortLevel = Math.floor(50 + (temp - 72) + (humidity - 42) + (co2 > 500 ? (co2 - 500) / 25 : 0)); + console.log(`comfortLevel: ${comfortLevel}`); return { body: { roomId: rid, @@ -45,7 +51,7 @@ async function UpdateSensor(context, bid, fid, rid, sid, sensorData) { temp: node.properties?.temp[0]?.value ?? "", humidity: node.properties?.humidity[0]?.value ?? "", co2: node.properties?.co2[0]?.value ?? "", - comfortLevel: node.properties?.comfortLevel[0]?.value ?? "" + comfortLevel: comfortLevel } }; } diff --git a/apps/react/connection-pool.js b/apps/react/connection-pool.js new file mode 100644 index 0000000..50e3441 --- /dev/null +++ b/apps/react/connection-pool.js @@ -0,0 +1,17 @@ +import * as signalR from "@microsoft/signalr"; + +const connections = new Map(); + +export function getConnection(url) { + if (!connections.has(url)) { + const connection = new signalR.HubConnectionBuilder() + .withUrl(url) + .withAutomaticReconnect() + .build(); + connections.set(url, { + connection: connection, + started: connection.start() + }); + } + return connections.get(url); +} \ No newline at end of file diff --git a/apps/react/index.js b/apps/react/index.js new file mode 100644 index 0000000..9b064c3 --- /dev/null +++ b/apps/react/index.js @@ -0,0 +1,4 @@ +import ReactionResult from './reaction-result'; +import ReactionListener from './reaction-listener'; + +export { ReactionResult, ReactionListener }; \ No newline at end of file diff --git a/apps/react/package.json b/apps/react/package.json new file mode 100644 index 0000000..8e6833e --- /dev/null +++ b/apps/react/package.json @@ -0,0 +1,15 @@ +{ + "name": "react-reactive-graph", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "dependencies": { + "@microsoft/signalr": "^6.0.8", + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "license": "ISC" +} diff --git a/apps/react/reaction-listener.js b/apps/react/reaction-listener.js new file mode 100644 index 0000000..a37e931 --- /dev/null +++ b/apps/react/reaction-listener.js @@ -0,0 +1,50 @@ +import {getConnection} from './connection-pool' + +export default class ReactionListener { + constructor(url, queryId, onMessage) { + this.url = url; + this.queryId = queryId; + this.onMessage = onMessage; + this.sigRConn = getConnection(url); + this.reloadData = []; + + let self = this; + + this.sigRConn.started + .then(result => { + self.sigRConn.connection.on(self.queryId, self.onMessage); + } + ); + } + + reload(callback) { + console.log("requesting reload for " + this.queryId); + let self = this; + + this.sigRConn.started + .then(_ => { + self.sigRConn.connection.stream("reload", this.queryId) + .subscribe({ + next: item => { + console.log(self.queryId + " reload next: " + JSON.stringify(item)); + switch (item['op']) { + case 'h': + self.reloadData = []; + break; + case 'r': + self.reloadData.push(item.payload.after); + break; + } + }, + complete: () => { + console.log(self.queryId + " reload complete"); + if (callback) { + callback(self.reloadData); + } + + }, + error: err => console.error(self.queryId + err) + }); + }); + } +} \ No newline at end of file diff --git a/apps/react/reaction-result.js b/apps/react/reaction-result.js new file mode 100644 index 0000000..0ab72c4 --- /dev/null +++ b/apps/react/reaction-result.js @@ -0,0 +1,137 @@ +import React from 'react'; +import {getConnection} from './connection-pool' + +export default class ReactionResult extends React.Component { + constructor(props) { + super(props); + this.mounted = false; + this.sigRConn = getConnection(props.url); + this.needsReload = !(props.noReload); + let self = this; + + this.onUpdate = item => { + console.log("update: "+ JSON.stringify(item)); + if (self.props.onMessage) + self.props.onMessage(item); + + if (item.seq) { + self.state.seq = item.seq; + } + + if (['i', 'u', 'd'].includes(item.op)) { + const itemKey = self.getKey(self, item); + if (item.op == 'd') { + if (self.props.ignoreDeletes) + return; + delete self.state.data[itemKey]; + } + else { + self.state.data[itemKey] = item.payload.after; + } + } + + if (item.op == 'x') { + switch (item.payload.kind) { + case 'deleted': + self.state.data = {}; + break; + } + } + + if (self.mounted) { + self.setState({ + data: self.state.data, + seq: self.state.seq + }); + } + }; + + this.state = { data: {} }; + } + + componentDidMount() { + let self = this; + console.log("mount"); + this.sigRConn.started + .then(result => { + self.sigRConn.connection.on(self.props.queryId, self.onUpdate); + if (self.needsReload) { + self.reload(); + self.needsReload = false; + } + }); + this.mounted = true; + } + + reload() { + console.log("requesting reload for " + this.props.queryId); + let self = this; + + this.sigRConn.connection.stream("reload", this.props.queryId) + .subscribe({ + next: item => { + console.log(self.props.queryId + " reload next: " + JSON.stringify(item)); + switch (item['op']) { + case 'h': + self.state.data = {}; + self.state.seq = item.seq; + break; + case 'r': + const itemKey = self.getKey(self, item); + self.state.data[itemKey] = item.payload.after; + if (self.props.onReloadItem) { + self.props.onReloadItem(item.payload.after); + } + break; + } + }, + complete: () => { + console.log(self.props.queryId + " reload complete"); + if (self.mounted) { + self.setState({ + data: self.state.data, + seq: self.state.seq + }); + } + console.log(self.props.queryId + " reload stream completed"); + }, + error: err => console.error(self.props.queryId + err) + }); + } + + componentWillUnmount() { + this.sigRConn.connection.off(this.props.queryId, this.onUpdate); + this.mounted = false; + } + + getKey(self, item) { + if (item.op == 'd' && item.payload.before) + return self.props.itemKey(item.payload.before); + return self.props.itemKey(item.payload.after); + } + + render() { + let self = this; + let keys = Object.keys(this.state.data); + + if (self.props.sortBy) { + keys = keys.sort((a, b) => { + let aVal = self.state.data[a][self.props.sortBy]; + let bVal = self.state.data[b][self.props.sortBy]; + if (aVal < bVal) return -1; + if (aVal > bVal) return 1; + return 0; + }); + } + + if (self.props.reverse) + keys.reverse(); + const listItems = keys.map((k) => { + let child = React.Children.only(self.props.children); + return React.cloneElement(child, _extends({ key: k }, this.state.data[k])); + }); + return listItems; + } +} + +function _extends() { _extends = Object.assign ? Object.assign.bind() : function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); } \ No newline at end of file diff --git a/apps/react/readme.md b/apps/react/readme.md new file mode 100644 index 0000000..7692bee --- /dev/null +++ b/apps/react/readme.md @@ -0,0 +1,42 @@ +# Reactive Graph React Components + +## Installation + +Add as a dependency to your package.json + +```json +{ + "dependencies": { + "react-reactive-graph": "file:../path to react-reactive-graph library" + } +} +``` + +Run `npm install` to setup a symlink + +## Usage + +```javascript +import { ReactionResult } from 'react-reactive-graph'; + +const ItemTemplate = props => {props.EmployeeName}{props.ManagerName}{props.IncidentDescription} + +function App() { + return ( +
+ + + + + + item.EmployeeName + item.IncidentDescription} + template={ItemTemplate} /> + +
EmployeeManagerIncident
+
+ ); +} +``` From a15f89f9d82a11300cb58a7d6e11b59d14c68aea Mon Sep 17 00:00:00 2001 From: Ruokun Niu Date: Tue, 27 Aug 2024 16:21:30 -0700 Subject: [PATCH 2/8] Added comments and removed unneeded files --- .../azure-resources/building-comfort.bicep | 323 ------------------ .../devops/data/load_graph.py | 8 +- .../devops/reactive-graph/query-alert.yaml | 7 +- .../reactive-graph/query-comfort-calc.yaml | 5 + .../devops/reactive-graph/query-ui.yaml | 3 + .../reactive-graph/reaction-gremlin.yaml | 65 ---- 6 files changed, 15 insertions(+), 396 deletions(-) delete mode 100644 apps/building-comfort/devops/azure-resources/building-comfort.bicep delete mode 100644 apps/building-comfort/devops/reactive-graph/reaction-gremlin.yaml diff --git a/apps/building-comfort/devops/azure-resources/building-comfort.bicep b/apps/building-comfort/devops/azure-resources/building-comfort.bicep deleted file mode 100644 index 22d47f5..0000000 --- a/apps/building-comfort/devops/azure-resources/building-comfort.bicep +++ /dev/null @@ -1,323 +0,0 @@ -param deploymentName string = 'building-comfort-demo' - -param cosmosAccountName string = 'reactive-graph-demo' - -param storageAccountName string = 'drasibuildingcomfort' - -param eventGridTopicName string = 'drasi-building-comfort' - -param location string = resourceGroup().location - - -resource contosoGraphDB 'Microsoft.DocumentDB/databaseAccounts@2021-04-15' = { - name: toLower(cosmosAccountName) - location: location - kind: 'GlobalDocumentDB' - properties: { - backupPolicy: { - type: 'Continuous' - } - databaseAccountOfferType: 'Standard' - locations: [ - { - locationName: location - } - ] - capabilities: [ - { - name: 'EnableGremlin' - } - { - name: 'EnableServerless' - } - ] - } - - resource database 'gremlinDatabases' = { - name: 'Contoso' - properties: { - resource: { - id: 'Contoso' - } - - } - - resource facilitiesGraph 'graphs' = { - name: 'Facilities' - properties: { - resource: { - id: 'Facilities' - partitionKey: { - kind: 'Hash' - paths: [ - '/name' - ] - } - indexingPolicy: { - indexingMode: 'consistent' - automatic: true - includedPaths: [ - { - path: '/*' - } - ] - } - } - } - } - } -} - -resource storageAccount 'Microsoft.Storage/storageAccounts@2022-05-01' = { - name: storageAccountName - location: location - sku: { - name: 'Standard_LRS' - } - properties: { - allowBlobPublicAccess: true - allowSharedKeyAccess: true - isHnsEnabled: true - accessTier: 'Hot' - dnsEndpointType: 'Standard' - } - kind: 'StorageV2' - - resource queueServices 'queueServices' = { - name: 'default' - - - resource sensorUpdatesQueue 'queues' = { - name: 'sensor-updates' - } - - resource floorChangesQueue 'queues' = { - name: 'floor-changes' - } - - resource roomAlertsQueue 'queues' = { - name: 'room-alerts' - } - - } -} - -resource eventGridTopic 'Microsoft.EventGrid/topics@2022-06-15' = { - name: eventGridTopicName - location: location - properties: { - inputSchema: 'CloudEventSchemaV1_0' - publicNetworkAccess: 'Enabled' - } - - resource sensorSubsciption 'eventSubscriptions' = { - name: 'sensor-updates' - properties: { - destination: { - endpointType: 'StorageQueue' - properties: { - resourceId: storageAccount.id - queueName: 'sensor-updates' - } - } - eventDeliverySchema: 'CloudEventSchemaV1_0' - filter: { - enableAdvancedFilteringOnArrays: true - advancedFilters: [ - { - key: 'source' - operatorType: 'StringBeginsWith' - values: [ - 'room-inputs' - ] - } - ] - } - } - } - - resource floorSubsciption 'eventSubscriptions' = { - name: 'floor-changes' - properties: { - destination: { - endpointType: 'StorageQueue' - properties: { - resourceId: storageAccount.id - queueName: 'floor-changes' - } - } - eventDeliverySchema: 'CloudEventSchemaV1_0' - filter: { - enableAdvancedFilteringOnArrays: true - advancedFilters: [ - { - key: 'source' - operatorType: 'StringBeginsWith' - values: [ - 'floor-inputs' - ] - } - ] - } - } - } - - resource roomAlertSubsciption 'eventSubscriptions' = { - name: 'room-alerts' - properties: { - destination: { - endpointType: 'StorageQueue' - properties: { - resourceId: storageAccount.id - queueName: 'room-alerts' - } - } - eventDeliverySchema: 'CloudEventSchemaV1_0' - filter: { - enableAdvancedFilteringOnArrays: true - advancedFilters: [ - { - key: 'source' - operatorType: 'StringBeginsWith' - values: [ - 'room-alert' - ] - } - ] - } - } - } - -} - -resource webContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2022-05-01' = { - name: '${storageAccount.name}/default/$web' - properties: { - publicAccess: 'Blob' - } -} - -resource azHostingPlan 'Microsoft.Web/serverfarms@2021-03-01' = { - name: '${deploymentName}-asp' - location: location - kind: 'functionapp' - sku: { - name: 'Y1' - tier: 'Dynamic' - size: 'Y1' - } -} - -resource azFunctionApp 'Microsoft.Web/sites@2021-03-01' = { - name: '${deploymentName}-app' - kind: 'functionapp' - location: location - identity: { - type: 'SystemAssigned' - } - properties: { - serverFarmId: azHostingPlan.id - enabled: true - siteConfig: { - cors: { - allowedOrigins: [ - '*' - ] - } - appSettings: [ - { - name: 'AzureWebJobsStorage' - value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' - } - { - name: 'FUNCTIONS_EXTENSION_VERSION' - value: '~4' - } - { - name: 'FUNCTIONS_WORKER_RUNTIME' - value: 'node' - } - { - name: 'WEBSITE_NODE_DEFAULT_VERSION' - value: '~16' - } - { - name: 'FACILITIES_URL' - value: 'wss://${contosoGraphDB.name}.gremlin.cosmos.azure.com:443/' - } - { - name: 'FACILITIES_DB_NAME' - value: 'Contoso' - } - { - name: 'FACILITIES_CNT_NAME' - value: 'Facilities' - } - { - name: 'FACILITIES_KEY' - value: contosoGraphDB.listKeys().primaryMasterKey - } - { - name: 'QueueStorageConnectionString' - value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' - } - ] - } - } -} - -resource contributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { - scope: subscription() - // This is the Storage Account Contributor role, which is the minimum role permission we can give. See https://docs.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#:~:text=17d1049b-9a84-46fb-8f53-869881c3d3ab - name: '17d1049b-9a84-46fb-8f53-869881c3d3ab' -} - -resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = { - name: 'DeploymentScript' - location: location -} - -resource roleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { - scope: storageAccount - name: guid(resourceGroup().id, managedIdentity.id, contributorRoleDefinition.id, deploymentName) - properties: { - roleDefinitionId: contributorRoleDefinition.id - principalId: managedIdentity.properties.principalId - principalType: 'ServicePrincipal' - } -} - -resource deploymentScript 'Microsoft.Resources/deploymentScripts@2020-10-01' = { - name: guid(resourceGroup().id, deploymentName) - location: location - kind: 'AzurePowerShell' - identity: { - type: 'UserAssigned' - userAssignedIdentities: { - '${managedIdentity.id}': {} - } - } - dependsOn: [ - roleAssignment - ] - properties: { - azPowerShellVersion: '3.0' - scriptContent: loadTextContent('./enable-static-website.ps1') - retentionInterval: 'PT4H' - environmentVariables: [ - { - name: 'ResourceGroupName' - value: resourceGroup().name - } - { - name: 'StorageAccountName' - value: storageAccount.name - } - ] - } -} - -output FrontEndUrl string = storageAccount.properties.primaryEndpoints.web -output CosmosDb string = contosoGraphDB.id -output FunctionApp string = azFunctionApp.properties.defaultHostName -output StorageAccount string = storageAccount.name diff --git a/apps/building-comfort/devops/data/load_graph.py b/apps/building-comfort/devops/data/load_graph.py index bd7dc18..494f802 100644 --- a/apps/building-comfort/devops/data/load_graph.py +++ b/apps/building-comfort/devops/data/load_graph.py @@ -12,7 +12,7 @@ def print_status_attributes(result): # # These responses includes total request units charged and total server latency time. # - # IMPORTANT: Make sure to consume ALL results returend by cliient tothe final status attributes + # IMPORTANT: Make sure to consume ALL results returend by client tothe final status attributes # for a request. Gremlin result are stream as a sequence of partial response messages # where the last response contents the complete status attributes set. # @@ -35,7 +35,6 @@ def create_room(client, building_num, floor_num, room_num): room_id = f"room_{building_num:02}_{floor_num:02}_{room_num:02}" room_name = f"Room {floor_num:02}{room_num:02}" floor_id = f"floor_{building_num:02}_{floor_num:02}" - roomComfort = 50 print(f"Creating Room - room_num:{room_num:02}, room_id: {room_id}, room_name:{room_name}") @@ -45,7 +44,6 @@ def create_room(client, building_num, floor_num, room_num): query += f".property('temp', {config.defaultRoomTemp})" query += f".property('humidity', {config.defaultRoomHumidity})" query += f".property('co2', {config.defaultRoomCo2})" - query += f".property('comfortLevel', {roomComfort})" callback = client.submitAsync(query) if callback.result() is not None: @@ -69,14 +67,12 @@ def create_floor(client, building_num, floor_num): floor_id = f"floor_{building_num:02}_{floor_num:02}" floor_name = f"Floor {floor_num:02}" building_id = f"building_{building_num:02}" - floorComfort = 50 print(f"Creating Floor - floor_num:{floor_num:02}, floor_id: {floor_id}, floor_name:{floor_name}") # Add Floor Node query = f"g.addV('Floor').property('id', '{floor_id}')" query += f".property('name', '{floor_name}')" - query += f".property('comfortLevel', {floorComfort})" callback = client.submitAsync(query) if callback.result() is not None: @@ -104,13 +100,11 @@ def create_building(client, building_num): building_id = f"building_{building_num:02}" building_name = f"Building {building_num:02}" - buildingComfort = 50 print(f"Creating Building - building_num:{building_num:02}, building_id: {building_id}, building_name:{building_name}") query = f"g.addV('Building').property('id', '{building_id}')" query += f".property('name', '{building_name}')" - query += f".property('comfortLevel', {buildingComfort})" callback = client.submitAsync(query) if callback.result() is not None: diff --git a/apps/building-comfort/devops/reactive-graph/query-alert.yaml b/apps/building-comfort/devops/reactive-graph/query-alert.yaml index 2b28301..d65c184 100644 --- a/apps/building-comfort/devops/reactive-graph/query-alert.yaml +++ b/apps/building-comfort/devops/reactive-graph/query-alert.yaml @@ -20,7 +20,9 @@ spec: RETURN RoomId, RoomName, ComfortLevel --- -# Calculates the comfort level of floors +# Calculates the average comfort level of all rooms in a floor +# Retrieves all floors that have a comfort level below 40 or above 50 +# Returns the floor ID, floor name and comfort level of each floor kind: ContinuousQuery apiVersion: v1 name: floor-alert @@ -45,6 +47,9 @@ spec: f.name AS FloorName, ComfortLevel --- +# Calculates the average comfort level of all floors in a building +# Returns the building ID, building Name and the comfort level if +# the comfort leve is outside the acceptable range of 40-50 kind: ContinuousQuery apiVersion: v1 name: building-alert diff --git a/apps/building-comfort/devops/reactive-graph/query-comfort-calc.yaml b/apps/building-comfort/devops/reactive-graph/query-comfort-calc.yaml index bf1630e..05335ef 100644 --- a/apps/building-comfort/devops/reactive-graph/query-comfort-calc.yaml +++ b/apps/building-comfort/devops/reactive-graph/query-comfort-calc.yaml @@ -1,3 +1,5 @@ +# Calculates the comfort level of the building by taking +# the average of the comfort level of all floors kind: ContinuousQuery apiVersion: v1 name: building-comfort-level-calc @@ -22,6 +24,8 @@ spec: elementId(b) AS BuildingId, ComfortLevel --- +# Calculates the comfort level of the floor by taking +# the average of the comfort level of all rooms kind: ContinuousQuery apiVersion: v1 name: floor-comfort-level-calc @@ -43,6 +47,7 @@ spec: elementId(f) AS FloorId, ComfortLevel --- +# Calculates the comfort level of a room kind: ContinuousQuery apiVersion: v1 name: room-comfort-level-calc diff --git a/apps/building-comfort/devops/reactive-graph/query-ui.yaml b/apps/building-comfort/devops/reactive-graph/query-ui.yaml index ba60d66..e2eaf65 100644 --- a/apps/building-comfort/devops/reactive-graph/query-ui.yaml +++ b/apps/building-comfort/devops/reactive-graph/query-ui.yaml @@ -1,3 +1,6 @@ +# This yaml file contains one continuous query with the name 'building-comfort-ui' +# It retrieves the relevant properties and calculates the comfort level of each room +# This information will be used in the frontend React app kind: ContinuousQuery apiVersion: v1 name: building-comfort-ui diff --git a/apps/building-comfort/devops/reactive-graph/reaction-gremlin.yaml b/apps/building-comfort/devops/reactive-graph/reaction-gremlin.yaml deleted file mode 100644 index 693097c..0000000 --- a/apps/building-comfort/devops/reactive-graph/reaction-gremlin.yaml +++ /dev/null @@ -1,65 +0,0 @@ -apiVersion: v1 -kind: Reaction -name: room-comfort-level-calc-gremlin -spec: - kind: Gremlin - queries: - room-comfort-level-calc: - properties: - AddedResultCommand: g.V('@RoomId').hasLabel('Room').property('comfortLevel', @ComfortLevel ) - UpdatedResultCommand: g.V('@after.RoomId').hasLabel('Room').property('comfortLevel', @after.ComfortLevel ) - DatabaseHost: - kind: Secret - name: comfy-gremlin - key: DatabaseHost - DatabasePrimaryKey: - kind: Secret - name: comfy-gremlin - key: DatabasePrimaryKey - DatabaseName: Contoso - DatabaseContainerName: Facilities - DatabasePort: "443" ---- -apiVersion: v1 -kind: Reaction -name: floor-comfort-level-calc-gremlin -spec: - kind: Gremlin - queries: - room-comfort-level-calc: - properties: - AddedResultCommand: g.V('@FloorId').hasLabel('Floor').property('comfortLevel', @ComfortLevel) - UpdatedResultCommand: g.V('@after.FloorId').hasLabel('Floor').property('comfortLevel', @after.ComfortLevel) - DatabaseHost: - kind: Secret - name: comfy-gremlin - key: DatabaseHost - DatabasePrimaryKey: - kind: Secret - name: comfy-gremlin - key: DatabasePrimaryKey - DatabaseName: Contoso - DatabaseContainerName: Facilities - DatabasePort: "443" ---- -apiVersion: v1 -kind: Reaction -name: building-comfort-level-calc-gremlin -spec: - kind: Gremlin - queries: - room-comfort-level-calc: - properties: - AddedResultCommand: g.V('@BuildingId').hasLabel('Building').property('comfortLevel', @ComfortLevel) - UpdatedResultCommand: g.V('@after.BuildingId').hasLabel('Building').property('comfortLevel', @after.ComfortLevel) - DatabaseHost: - kind: Secret - name: comfy-gremlin - key: DatabaseHost - DatabasePrimaryKey: - kind: Secret - name: comfy-gremlin - key: DatabasePrimaryKey - DatabaseName: Contoso - DatabaseContainerName: Facilities - DatabasePort: "443" \ No newline at end of file From 685f67809c6c2b826a5dfa8842a10232fbe4a7ba Mon Sep 17 00:00:00 2001 From: Ruokun Niu Date: Tue, 27 Aug 2024 16:55:16 -0700 Subject: [PATCH 3/8] configured readme and files --- apps/building-comfort/app/src/App.js | 15 +- apps/building-comfort/app/src/config.json | 2 +- .../azure-resources/building-comfort.bicep | 323 ++++++++++++++++++ apps/building-comfort/readme.md | 100 +----- 4 files changed, 340 insertions(+), 100 deletions(-) create mode 100644 apps/building-comfort/devops/azure-resources/building-comfort.bicep diff --git a/apps/building-comfort/app/src/App.js b/apps/building-comfort/app/src/App.js index 67de1d1..be1fbdd 100644 --- a/apps/building-comfort/app/src/App.js +++ b/apps/building-comfort/app/src/App.js @@ -25,6 +25,7 @@ initBldQuery(); initFloorQuery(); initUIQuery(); +// This function listens to the 'building-comfort-ui' query, which lives in the query-ui.yaml file function initUIQuery() { uiListener = new ReactionListener(config.signalRUrl, config.uiQueryId, change => { if (change.op === 'x') { @@ -38,7 +39,7 @@ function initUIQuery() { else removeRoomData(change.payload.after); } - else { + else { // if op is 'i' or 'u' roomSubject.next(change.payload.after); upsertRoomData(change.payload.after); } @@ -66,6 +67,7 @@ function initUIQuery() { }); } +// This function listens to the 'floor-comfort-level-calc' query function initFloorQuery() { floorListener = new ReactionListener(config.signalRUrl, config.avgRoomQueryId, change => { if (change.op === 'x') { @@ -83,6 +85,7 @@ function initFloorQuery() { }); } +// This function listens to the 'building-comfort-level-calc' query function initBldQuery() { bldListener = new ReactionListener(config.signalRUrl, config.avgFloorQueryId, change => { if (change.op === 'x') { @@ -146,7 +149,7 @@ function Building(props) { React.useEffect(() => { let subscription = bldSubject.subscribe(v => { - if (v.BuildingId == props.building.id) + if (v.BuildingId === props.building.id) setBldComfort(v); }); @@ -154,7 +157,8 @@ function Building(props) { subscription.unsubscribe(); }; }); - + + // Init setup let level = bldComfort.ComfortLevel ?? 0; return ( @@ -163,12 +167,14 @@ function Building(props) { {level}

Comfort Alerts

+ {/* If the comfort level of a room is outside of the desired range, a warning will be created here */} item.RoomId}> + {/* If the comfort level of a floor is outside of the desired range, a warning will be created here */} + {/* inits the floors */} {Array.from(props.building.floors.values()).map(floor => )} @@ -282,12 +289,14 @@ function Room(props) { ) } +// This function creates a warning that displays the room name and the comfort level of the room function RoomComfortAlert(props) { return ( {props.RoomName} = {props.ComfortLevel} ); } +// This function creates a warning that displays the floor name and the comfort level of the floor function FloorComfortAlert(props) { return ( {props.FloorName} = {props.ComfortLevel} diff --git a/apps/building-comfort/app/src/config.json b/apps/building-comfort/app/src/config.json index f3ea349..539d9c6 100644 --- a/apps/building-comfort/app/src/config.json +++ b/apps/building-comfort/app/src/config.json @@ -1,6 +1,6 @@ { "crudApiUrl": "http://localhost:7071", - "signalRUrl": "http://localhost:5001/hub", + "signalRUrl": "http://localhost:5001/hub", "avgFloorQueryId": "building-comfort-level-calc", "uiQueryId": "building-comfort-ui", "avgRoomQueryId": "floor-comfort-level-calc", diff --git a/apps/building-comfort/devops/azure-resources/building-comfort.bicep b/apps/building-comfort/devops/azure-resources/building-comfort.bicep new file mode 100644 index 0000000..22d47f5 --- /dev/null +++ b/apps/building-comfort/devops/azure-resources/building-comfort.bicep @@ -0,0 +1,323 @@ +param deploymentName string = 'building-comfort-demo' + +param cosmosAccountName string = 'reactive-graph-demo' + +param storageAccountName string = 'drasibuildingcomfort' + +param eventGridTopicName string = 'drasi-building-comfort' + +param location string = resourceGroup().location + + +resource contosoGraphDB 'Microsoft.DocumentDB/databaseAccounts@2021-04-15' = { + name: toLower(cosmosAccountName) + location: location + kind: 'GlobalDocumentDB' + properties: { + backupPolicy: { + type: 'Continuous' + } + databaseAccountOfferType: 'Standard' + locations: [ + { + locationName: location + } + ] + capabilities: [ + { + name: 'EnableGremlin' + } + { + name: 'EnableServerless' + } + ] + } + + resource database 'gremlinDatabases' = { + name: 'Contoso' + properties: { + resource: { + id: 'Contoso' + } + + } + + resource facilitiesGraph 'graphs' = { + name: 'Facilities' + properties: { + resource: { + id: 'Facilities' + partitionKey: { + kind: 'Hash' + paths: [ + '/name' + ] + } + indexingPolicy: { + indexingMode: 'consistent' + automatic: true + includedPaths: [ + { + path: '/*' + } + ] + } + } + } + } + } +} + +resource storageAccount 'Microsoft.Storage/storageAccounts@2022-05-01' = { + name: storageAccountName + location: location + sku: { + name: 'Standard_LRS' + } + properties: { + allowBlobPublicAccess: true + allowSharedKeyAccess: true + isHnsEnabled: true + accessTier: 'Hot' + dnsEndpointType: 'Standard' + } + kind: 'StorageV2' + + resource queueServices 'queueServices' = { + name: 'default' + + + resource sensorUpdatesQueue 'queues' = { + name: 'sensor-updates' + } + + resource floorChangesQueue 'queues' = { + name: 'floor-changes' + } + + resource roomAlertsQueue 'queues' = { + name: 'room-alerts' + } + + } +} + +resource eventGridTopic 'Microsoft.EventGrid/topics@2022-06-15' = { + name: eventGridTopicName + location: location + properties: { + inputSchema: 'CloudEventSchemaV1_0' + publicNetworkAccess: 'Enabled' + } + + resource sensorSubsciption 'eventSubscriptions' = { + name: 'sensor-updates' + properties: { + destination: { + endpointType: 'StorageQueue' + properties: { + resourceId: storageAccount.id + queueName: 'sensor-updates' + } + } + eventDeliverySchema: 'CloudEventSchemaV1_0' + filter: { + enableAdvancedFilteringOnArrays: true + advancedFilters: [ + { + key: 'source' + operatorType: 'StringBeginsWith' + values: [ + 'room-inputs' + ] + } + ] + } + } + } + + resource floorSubsciption 'eventSubscriptions' = { + name: 'floor-changes' + properties: { + destination: { + endpointType: 'StorageQueue' + properties: { + resourceId: storageAccount.id + queueName: 'floor-changes' + } + } + eventDeliverySchema: 'CloudEventSchemaV1_0' + filter: { + enableAdvancedFilteringOnArrays: true + advancedFilters: [ + { + key: 'source' + operatorType: 'StringBeginsWith' + values: [ + 'floor-inputs' + ] + } + ] + } + } + } + + resource roomAlertSubsciption 'eventSubscriptions' = { + name: 'room-alerts' + properties: { + destination: { + endpointType: 'StorageQueue' + properties: { + resourceId: storageAccount.id + queueName: 'room-alerts' + } + } + eventDeliverySchema: 'CloudEventSchemaV1_0' + filter: { + enableAdvancedFilteringOnArrays: true + advancedFilters: [ + { + key: 'source' + operatorType: 'StringBeginsWith' + values: [ + 'room-alert' + ] + } + ] + } + } + } + +} + +resource webContainer 'Microsoft.Storage/storageAccounts/blobServices/containers@2022-05-01' = { + name: '${storageAccount.name}/default/$web' + properties: { + publicAccess: 'Blob' + } +} + +resource azHostingPlan 'Microsoft.Web/serverfarms@2021-03-01' = { + name: '${deploymentName}-asp' + location: location + kind: 'functionapp' + sku: { + name: 'Y1' + tier: 'Dynamic' + size: 'Y1' + } +} + +resource azFunctionApp 'Microsoft.Web/sites@2021-03-01' = { + name: '${deploymentName}-app' + kind: 'functionapp' + location: location + identity: { + type: 'SystemAssigned' + } + properties: { + serverFarmId: azHostingPlan.id + enabled: true + siteConfig: { + cors: { + allowedOrigins: [ + '*' + ] + } + appSettings: [ + { + name: 'AzureWebJobsStorage' + value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' + } + { + name: 'FUNCTIONS_EXTENSION_VERSION' + value: '~4' + } + { + name: 'FUNCTIONS_WORKER_RUNTIME' + value: 'node' + } + { + name: 'WEBSITE_NODE_DEFAULT_VERSION' + value: '~16' + } + { + name: 'FACILITIES_URL' + value: 'wss://${contosoGraphDB.name}.gremlin.cosmos.azure.com:443/' + } + { + name: 'FACILITIES_DB_NAME' + value: 'Contoso' + } + { + name: 'FACILITIES_CNT_NAME' + value: 'Facilities' + } + { + name: 'FACILITIES_KEY' + value: contosoGraphDB.listKeys().primaryMasterKey + } + { + name: 'QueueStorageConnectionString' + value: 'DefaultEndpointsProtocol=https;AccountName=${storageAccount.name};EndpointSuffix=${environment().suffixes.storage};AccountKey=${storageAccount.listKeys().keys[0].value}' + } + ] + } + } +} + +resource contributorRoleDefinition 'Microsoft.Authorization/roleDefinitions@2018-01-01-preview' existing = { + scope: subscription() + // This is the Storage Account Contributor role, which is the minimum role permission we can give. See https://docs.microsoft.com/en-us/azure/role-based-access-control/built-in-roles#:~:text=17d1049b-9a84-46fb-8f53-869881c3d3ab + name: '17d1049b-9a84-46fb-8f53-869881c3d3ab' +} + +resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2018-11-30' = { + name: 'DeploymentScript' + location: location +} + +resource roleAssignment 'Microsoft.Authorization/roleAssignments@2020-04-01-preview' = { + scope: storageAccount + name: guid(resourceGroup().id, managedIdentity.id, contributorRoleDefinition.id, deploymentName) + properties: { + roleDefinitionId: contributorRoleDefinition.id + principalId: managedIdentity.properties.principalId + principalType: 'ServicePrincipal' + } +} + +resource deploymentScript 'Microsoft.Resources/deploymentScripts@2020-10-01' = { + name: guid(resourceGroup().id, deploymentName) + location: location + kind: 'AzurePowerShell' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${managedIdentity.id}': {} + } + } + dependsOn: [ + roleAssignment + ] + properties: { + azPowerShellVersion: '3.0' + scriptContent: loadTextContent('./enable-static-website.ps1') + retentionInterval: 'PT4H' + environmentVariables: [ + { + name: 'ResourceGroupName' + value: resourceGroup().name + } + { + name: 'StorageAccountName' + value: storageAccount.name + } + ] + } +} + +output FrontEndUrl string = storageAccount.properties.primaryEndpoints.web +output CosmosDb string = contosoGraphDB.id +output FunctionApp string = azFunctionApp.properties.defaultHostName +output StorageAccount string = storageAccount.name diff --git a/apps/building-comfort/readme.md b/apps/building-comfort/readme.md index 72e1ca0..b102c5c 100644 --- a/apps/building-comfort/readme.md +++ b/apps/building-comfort/readme.md @@ -4,13 +4,7 @@ This README describes how to deploy the Building Comfort Demo for an Azure-hoste Before proceeding with this doc, it is recommended that you first be familiar with the overview of the Building Comfort Demo, its architecture, and how to self-host it in your cluster as described at: -> https://project-drasi-docs.azurewebsites.net/administrator/sample-app-deployment/building-comfort/ - -Hosting the Building Comfort Demo in Azure is similar to self-hosting it in your cluster with a couple of key differences: - -1. The applications are hosted in Azure and not run locally on your machine. -2. Other additional Azure services need to be deployed to support that, such as Azure Functions and Azure Storage. -3. Optional demo components such as Teams alerts through Power Automate and using an IOT simulator are also described here. +> https://drasi-docs.azurewebsites.net/administrator/sample-app-deployment/building-comfort/ ## Demo Contents @@ -20,10 +14,8 @@ This folder contains the following sub-folders: - [devops](./devops/) - contains the files used to deploy and configure the Building Comfort Demo. - [azure-resources](./devops/azure-resources/) - contains the Bicep and configuration files used to deploy the Azure resources required by the Building Comfort Demo. - [data](./devops/data/) - contains the Python script used to populate the database used by the Building Comfort Demo with initial demo data. - - [power-automate](./devops/power-automate/) - contains the optional Power Automate flow used to illustrate sending alerts to Microsoft Teams. - - [reactive-graph](./devops/reactive-graph/) - contains the Kubectl YAML files used to apply the Drasi components for the Building Comfort Demo. + - [reactive-graph](./devops/reactive-graph/) - contains the Drasi YAML files used to apply the Drasi components for the Building Comfort Demo. - [functions](./functions/) - contains Azure Functions used by the app and simulator to read and write to the source Cosmos database. -- [iot-simulator](./iot-simulator/) - contains the code of a command line tool that can be used to simulate high volumes of sensor data automatically being updated in the Building Comfort Demo on a regular interval. ## Deploy Building Comfort Demo @@ -60,8 +52,6 @@ Note that Azure deployments are intended to be idempotent, and rerunning the abo Once the deployment is complete, there should be a CosmosDB Gremlin Graph database named `Contoso` with an empty `Facilities` graph, and this step is the same as in the self-hosting case. -> ⚠️ Until Full Fidelity Change Feed (FFCF) is a public feature for Cosmos DB, you will also need to submit a request to enable that -> feature for your Cosmos DB account through the [Private Preview form](https://forms.office.com/pages/responsepage.aspx?id=v4j5cvGGr0GRqy180BHbR9ecQmQM5J5LlXYOPoIbyzdUOFVRNUlLUlpRV0dXMjFRNVFXMDNRRjVDNy4u). To populate the graph with the demo data, you'll need to create a `config.py` file under the `devops/data` subfolder as described by the [README](./devops/data/readme.md) in that folder. You can also use the template from the self-hosting instructions and edit the `cosmosUri` and `cosmosPassword` values to match your created Cosmos DB account: @@ -131,29 +121,9 @@ kubectl apply -f query-ui.yaml #### Deploy the reactions -From the `devops/reactive-graph` subfolder, edit the `reaction-gremlin.yaml` file to specify your Gremlin graph in the Cosmos DB instance: - -- `DatabaseHost` with the host DNS name for the Gremlin endpoint. This is the same as the `cosmosUri` in `config.py` without the `wss://` prefix or the port number. -- `DatabasePrimaryKey` with the primary key, same as the `cosmosPassword` in `config.py`. - -Similarly, edit the `reaction-eventgrid.yaml` file to specify your Event Grid topic: - -- `EventGridUri` with the Event Grid topic endpoint. -- `EventGridKey` with the Event Grid topic key. +From the `devops/reactive-graph` subfolder,ppply the Reaction yaml files with `kubectl` to your AKS cluster running Drasi: ```bash -# To get the topic endpoint for EventGridUri -az eventgrid topic show --resource-group --name --query endpoint -o tsv - -# To get the primary key for EventGridKey -az eventgrid topic key list --resource-group --name --query key1 -o tsv -``` - -Apply the Reaction yaml files with `kubectl` to your AKS cluster running Drasi: - -```bash -kubectl apply -f reaction-gremlin.yaml -kubectl apply -f reaction-eventgrid.yaml kubectl apply -f reaction-signalr.yaml ``` @@ -176,39 +146,6 @@ For example, if you used the default `rg-building-comfort-demo` for the `deploym func azure functionapp publish rg-building-comfort-demo-app --javascript ``` -#### Configure ingress for the frontend app to signalR reaction - -The frontend app is served as a static web site that is executed in the client browser, which needs to be able to connect to the signalR Reaction hub. To enable this, you can configure an ingress for the signalR reaction so that it has a Public IP address that can be accessed from the browser. - -A basic ingress setup using NGINX can be deployed using Helm as follows: - -```bash -helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx - -helm install ingress-nginx ingress-nginx/ingress-nginx \ ---create-namespace \ ---namespace ingress-basic \ ---set controller.service.annotations."service\.beta\.kubernetes\.io/azure-load-balancer-health-probe-request-path"=/healthz -``` - -In AKS this will automatically create a Public IP Resource and the appropriate Network Security Group (NSG) rules to allow access to it. - -You can then deploy an Ingress resource to route the traffic to the signalR Reaction. From the `building-comfort/devops` folder: - -```bash -kubectl apply -f ingress-signalr.yaml -``` - -You can then get the public IP address as returned by the `ADDRESS` of the ingress from running: - -```bash -kubectl get ingress building-comfort-signalr-ingress -``` - -> ⚠️ If you are not connected to CorpNet, you will still need to be connected to [Azure VPN for Developers](https://eng.ms/docs/microsoft-security/security/azure-security/security-health-analytics/network-isolation/tsgs/howtos/work-from-home-guidance/work-from-home-guidance#managed-vpn-azvpndev-access-status-jan-2023) (AzVPNDev) to access the Public IP address. -> You can [request access](https://eng.ms/docs/microsoft-security/security/azure-security/security-health-analytics/network-isolation/tsgs/howtos/work-from-home-guidance/work-from-home-guidance#managed-vpn-azvpndev-access-status-jan-2023) to the pilot, with the AzVPNDev configuration automatically made available to your Windows machine via InTune. This is not currently available for MacOS. -> -> This additional step is required because of Management Group Level Network Security Rules (a.k.a. [Simply Secure V2](https://eng.ms/docs/microsoft-security/security/azure-security/security-health-analytics/network-isolation/tsgs/azurenetworkmanager/programoverview)) imposed on all Non-prod environments, which includes the Azure Incubations Dev subscription. These rules supersede the NSG rules for the AKS cluster and do not allow access to the Public IP address. #### Configure and deploy the frontend React app @@ -217,7 +154,7 @@ Edit the `config.json` file under `app/src` subfolder to point to the URLs for y ```json { "crudApiUrl": "https://-app.azurewebsites.net", // Functions app URL - "signalRUrl": "https:///hub", // Public URL to SignalR reaction + "signalRUrl": "https:///hub", // Public URL to SignalR reaction ... } ``` @@ -262,15 +199,6 @@ azcopy login azcopy sync './build' 'https://.blob.core.windows.net/$web' ``` -### 5. [Optional] Create a Power Automate flow to send alerts to the Reactive Graph Teams channel - -1. Navigate to the [Power Automate site](https://make.preview.powerautomate.com/) and select `My Flows` -> `Import` -> `Import Package (Legacy)`. -2. Click the `Import` button and select the `RoomAlert-PowerAutomateFlow.zip` file in the `devops/power-automate` subfolder. -3. In the Import package wizard after the upload is complete, configure: - 1. _Azure Queues Connection_ to point to one of the Event Subscription StorageQueues of the Event Grid topic. - 2. _Microsoft Teams Connection_ to point to the Teams connection where you want to receive the alerts. - -> ⚠️ The RoomAlert-PowerAutomateFlow.zip defaults to updating an existing deployed workflow, so you may need to delete the existing workflow before importing the zip file. ## Running the Building Comfort Demo @@ -287,23 +215,3 @@ For visual demonstrations, you can use the Gremlin UI to the Cosmos DB graph ins 5. From the _Results_ pane after loading the graph, you can click on any of the results to view the properties for that node. You can also click on the ✏️ icon to edit the properties for that node. Note that while you can edit the `comfortLevel` value directly, this is a calculated value generated by the Gremlin Reaction, so for demo purposes it's best to edit one of the 3 sensor values that affect it instead: `temperature`, `humidity`, or `co2`. - -### Running the IOT Simulator - -The application can also be driven as a simulation of IoT devices sending data to the backend to demonstrate the volume of data and the real-time processing of the data by the Reactive Graph. - -Under the `iot-simulator` subfolder, edit the `config.json` file to point the `functionUrl` to your Functions app URL: - -```json -{ - "interval": 500, - "functionUrl": "https://-app.azurewebsites.net" -} -``` - -You can then run the simulator with: - -```bash -npm install -node iot-sim.js -``` From 0e61b5ffeb6818f8e864789956aefa0a75fcc6c6 Mon Sep 17 00:00:00 2001 From: Ruokun Niu Date: Tue, 27 Aug 2024 17:02:13 -0700 Subject: [PATCH 4/8] more comments --- apps/building-comfort/app/src/App.js | 20 +++++++------------ .../functions/sensor/index.js | 6 +----- 2 files changed, 8 insertions(+), 18 deletions(-) diff --git a/apps/building-comfort/app/src/App.js b/apps/building-comfort/app/src/App.js index be1fbdd..dbd3532 100644 --- a/apps/building-comfort/app/src/App.js +++ b/apps/building-comfort/app/src/App.js @@ -32,14 +32,7 @@ function initUIQuery() { return; } - if (change.op === 'd') { - if (change.payload.before) - removeRoomData(change.payload.before); - - else - removeRoomData(change.payload.after); - } - else { // if op is 'i' or 'u' + if (change.op !== 'd') { // if op 'u' or 'i' roomSubject.next(change.payload.after); upsertRoomData(change.payload.after); } @@ -126,10 +119,6 @@ function upsertRoomData(data) { floor.rooms.set(data.RoomId, data); } -function removeRoomData(data) { - -} - function App() { const [data, setData] = React.useState(Array.from(buildings.values())); @@ -218,6 +207,7 @@ function Floor(props) { {level} + {/* Setup the rooms in the floor */} {Array.from(props.floor.rooms.values()).map(initRoom => ( ))} @@ -234,7 +224,8 @@ function Room(props) { React.useEffect(() => { let subscription = roomSubject.subscribe(v => { - if (v.RoomId == props.initRoom.RoomId) { + if (v.RoomId === props.initRoom.RoomId) { + // Animation to show the change in values let prev = buildings.get(props.initRoom.BuildingId) .floors.get(props.initRoom.FloorId) .rooms.get(props.initRoom.RoomId); @@ -279,9 +270,11 @@ function Room(props) { CO2: {room.CO2}
+ {/* This will cause the comfort level to go below the desired range */} + {/* Resets the comfort level to 46, which is in the desired range of 40-50 */} @@ -304,6 +297,7 @@ function FloorComfortAlert(props) { } async function updateRoom(buildingId, floorId, roomId, temperature, humidity, co2) { + // Sends a POST request to the Backend function to update the temperature, humidity, and CO2 levels of the room await axios.post(`${config.crudApiUrl}/building/${buildingId}/floor/${floorId}/room/${roomId}/sensor/temp`, { value: temperature }); await delay(200); await axios.post(`${config.crudApiUrl}/building/${buildingId}/floor/${floorId}/room/${roomId}/sensor/humidity`, { value: humidity }); diff --git a/apps/building-comfort/functions/sensor/index.js b/apps/building-comfort/functions/sensor/index.js index 46fa948..1955537 100644 --- a/apps/building-comfort/functions/sensor/index.js +++ b/apps/building-comfort/functions/sensor/index.js @@ -16,8 +16,6 @@ const client = new gremlin.driver.Client( ); async function UpdateSensor(context, bid, fid, rid, sid, sensorData) { - // context.log(`UpdateSensor - rid:${rid}, sid:${sid}, sensorData:${JSON.stringify(sensorData)}`); - var query = `g.V(bid).hasLabel("Building")`; query += `.in("PART_OF").hasLabel("Floor").hasId(fid)`; query += `.in("PART_OF").hasLabel("Room").hasId(rid)`; @@ -33,13 +31,13 @@ async function UpdateSensor(context, bid, fid, rid, sid, sensorData) { const res = await client.submit(query, params); - // context.log(`UpdateOrder - res: ${JSON.stringify(res)}`); const node = res.first(); let roomName = node.properties?.name[0]?.value ?? ""; let temp = node.properties?.temp[0]?.value ?? ""; let humidity = node.properties?.humidity[0]?.value ?? ""; let co2 = node.properties?.co2[0]?.value ?? ""; + // The comfort level is calculated based on the same formula used in the continuous queries let comfortLevel = Math.floor(50 + (temp - 72) + (humidity - 42) + (co2 > 500 ? (co2 - 500) / 25 : 0)); console.log(`comfortLevel: ${comfortLevel}`); return { @@ -57,8 +55,6 @@ async function UpdateSensor(context, bid, fid, rid, sid, sensorData) { } module.exports = async function (context, req) { - // context.log(`request: ${JSON.stringify(req)}`); - var result = {}; switch (req.method) { From 645de8fa6ddf1a19f6aabd1139aa1e9a28d9df40 Mon Sep 17 00:00:00 2001 From: Ruokun Niu Date: Wed, 28 Aug 2024 08:48:14 -0700 Subject: [PATCH 5/8] updated directory to be drasi --- .../query-alert.yaml | 0 .../query-comfort-calc.yaml | 0 .../{reactive-graph => drasi}/query-ui.yaml | 0 .../reaction-signalr.yaml | 0 .../source-facilities.yaml | 0 .../functions/building/function.json | 19 +++ .../functions/building/index.js | 136 ++++++++++++++++++ .../functions/floor/function.json | 19 +++ .../building-comfort/functions/floor/index.js | 114 +++++++++++++++ .../functions/room/function.json | 19 +++ apps/building-comfort/functions/room/index.js | 95 ++++++++++++ apps/building-comfort/readme.md | 10 +- 12 files changed, 407 insertions(+), 5 deletions(-) rename apps/building-comfort/devops/{reactive-graph => drasi}/query-alert.yaml (100%) rename apps/building-comfort/devops/{reactive-graph => drasi}/query-comfort-calc.yaml (100%) rename apps/building-comfort/devops/{reactive-graph => drasi}/query-ui.yaml (100%) rename apps/building-comfort/devops/{reactive-graph => drasi}/reaction-signalr.yaml (100%) rename apps/building-comfort/devops/{reactive-graph => drasi}/source-facilities.yaml (100%) create mode 100644 apps/building-comfort/functions/building/function.json create mode 100644 apps/building-comfort/functions/building/index.js create mode 100644 apps/building-comfort/functions/floor/function.json create mode 100644 apps/building-comfort/functions/floor/index.js create mode 100644 apps/building-comfort/functions/room/function.json create mode 100644 apps/building-comfort/functions/room/index.js diff --git a/apps/building-comfort/devops/reactive-graph/query-alert.yaml b/apps/building-comfort/devops/drasi/query-alert.yaml similarity index 100% rename from apps/building-comfort/devops/reactive-graph/query-alert.yaml rename to apps/building-comfort/devops/drasi/query-alert.yaml diff --git a/apps/building-comfort/devops/reactive-graph/query-comfort-calc.yaml b/apps/building-comfort/devops/drasi/query-comfort-calc.yaml similarity index 100% rename from apps/building-comfort/devops/reactive-graph/query-comfort-calc.yaml rename to apps/building-comfort/devops/drasi/query-comfort-calc.yaml diff --git a/apps/building-comfort/devops/reactive-graph/query-ui.yaml b/apps/building-comfort/devops/drasi/query-ui.yaml similarity index 100% rename from apps/building-comfort/devops/reactive-graph/query-ui.yaml rename to apps/building-comfort/devops/drasi/query-ui.yaml diff --git a/apps/building-comfort/devops/reactive-graph/reaction-signalr.yaml b/apps/building-comfort/devops/drasi/reaction-signalr.yaml similarity index 100% rename from apps/building-comfort/devops/reactive-graph/reaction-signalr.yaml rename to apps/building-comfort/devops/drasi/reaction-signalr.yaml diff --git a/apps/building-comfort/devops/reactive-graph/source-facilities.yaml b/apps/building-comfort/devops/drasi/source-facilities.yaml similarity index 100% rename from apps/building-comfort/devops/reactive-graph/source-facilities.yaml rename to apps/building-comfort/devops/drasi/source-facilities.yaml diff --git a/apps/building-comfort/functions/building/function.json b/apps/building-comfort/functions/building/function.json new file mode 100644 index 0000000..6ed8bd2 --- /dev/null +++ b/apps/building-comfort/functions/building/function.json @@ -0,0 +1,19 @@ +{ + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get" + ], + "route": "building/{bid?}" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} \ No newline at end of file diff --git a/apps/building-comfort/functions/building/index.js b/apps/building-comfort/functions/building/index.js new file mode 100644 index 0000000..1912098 --- /dev/null +++ b/apps/building-comfort/functions/building/index.js @@ -0,0 +1,136 @@ +const gremlin = require('gremlin'); + +// Facilities Client +const authenticator = new gremlin.driver.auth.PlainTextSaslAuthenticator( + `/dbs/${process.env["FACILITIES_DB_NAME"]}/colls/${process.env["FACILITIES_CNT_NAME"]}`, process.env["FACILITIES_KEY"] +) + +const client = new gremlin.driver.Client( + process.env["FACILITIES_URL"], + { + authenticator, + traversalsource: "g", + rejectUnauthorized: true, + mimeType: "application/vnd.gremlin-v2.0+json" + } +); + +async function GetBuildingById(context, id, includeFloors = false, includeRooms = false) { + // context.log(`GetBuildingById: ${JSON.stringify(id)}`); + + const res = await client.submit(`g.V(id).hasLabel("Building")`, { id }); + const node = res.first(); + + if (node) { + return { + body: { + id: node.id, + name: node.properties?.name[0]?.value ?? "", + comfortLevel: node.properties?.comfortLevel[0]?.value ?? "", + floors: includeFloors ? await GetAllFloorsForBuilding(context, id, includeRooms) : undefined + } + }; + } else { + // TODO + } +} + +async function GetAllBuildings(context, includeFloors = false, includeRooms = false) { + // context.log(`GetAllBuildings`); + + const buildings = []; + var readable = client.stream(`g.V().hasLabel("Building")`, {}, { batchSize: 100 }); + + try { + for await (const result of readable) { + for (const node of result.toArray()) { + const v = { + id: node.id, + name: node.properties?.name[0]?.value ?? "", + comfortLevel: node.properties?.comfortLevel[0]?.value ?? "", + floors: includeFloors ? await GetAllFloorsForBuilding(context, node.id, includeRooms) : undefined + }; + buildings.push(v); + } + } + } catch (err) { + console.error(err.stack); + } + + return { + body: buildings + }; +} + +async function GetAllFloorsForBuilding(context, bid, includeRooms = false) { + // context.log(`GetAllFloorsForBuilding`); + + const floors = []; + var readable = client.stream(`g.V(bid).hasLabel("Building").in("PART_OF").hasLabel("Floor")`, { bid }, { batchSize: 100 }); + + try { + for await (const result of readable) { + for (const node of result.toArray()) { + const v = { + id: node.id, + name: node.properties?.name[0]?.value ?? "", + comfortLevel: node.properties?.comfortLevel[0]?.value ?? "", + rooms: includeRooms ? await GetAllRoomsForFloor(context, node.id) : undefined + }; + floors.push(v); + } + } + } catch (err) { + console.error(err.stack); + } + + return floors; +} + +async function GetAllRoomsForFloor(context, fid) { + // context.log(`GetAllRoomsForFloor`); + + const rooms = []; + var readable = client.stream(`g.V(fid).hasLabel("Floor").in("PART_OF").hasLabel("Room")`, { fid }, { batchSize: 100 }); + + try { + for await (const result of readable) { + for (const node of result.toArray()) { + const v = { + id: node.id, + name: node.properties?.name[0]?.value ?? "", + temp: node.properties?.temp[0]?.value ?? "", + humidity: node.properties?.humidity[0]?.value ?? "", + co2: node.properties?.co2[0]?.value ?? "", + comfortLevel: node.properties?.comfortLevel[0]?.value ?? "" + }; + rooms.push(v); + } + } + } catch (err) { + console.error(err.stack); + } + + return rooms; +} + +module.exports = async function (context, req) { + // context.log(`request: ${JSON.stringify(req)}`); + + var result = {}; + + switch (req.method) { + case "GET": + const includeFloors = "includeFloors" in req.query && req.query.includeFloors != "false"; + const includeRooms = "includeRooms" in req.query && req.query.includeRooms != "false"; + if (req.params.bid) { + result = await GetBuildingById(context, req.params.bid, includeFloors, includeRooms); + } else { + result = await GetAllBuildings(context, includeFloors, includeRooms); + } + break; + default: + break; + } + return result; +} \ No newline at end of file diff --git a/apps/building-comfort/functions/floor/function.json b/apps/building-comfort/functions/floor/function.json new file mode 100644 index 0000000..f566157 --- /dev/null +++ b/apps/building-comfort/functions/floor/function.json @@ -0,0 +1,19 @@ +{ + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get" + ], + "route": "building/{bid}/floor/{fid?}" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} \ No newline at end of file diff --git a/apps/building-comfort/functions/floor/index.js b/apps/building-comfort/functions/floor/index.js new file mode 100644 index 0000000..9e204c9 --- /dev/null +++ b/apps/building-comfort/functions/floor/index.js @@ -0,0 +1,114 @@ +const gremlin = require('gremlin'); + +// Facilities Client +const authenticator = new gremlin.driver.auth.PlainTextSaslAuthenticator( + `/dbs/${process.env["FACILITIES_DB_NAME"]}/colls/${process.env["FACILITIES_CNT_NAME"]}`, process.env["FACILITIES_KEY"] +) + +const client = new gremlin.driver.Client( + process.env["FACILITIES_URL"], + { + authenticator, + traversalsource: "g", + rejectUnauthorized: true, + mimeType: "application/vnd.gremlin-v2.0+json" + } +); + +async function GetFloorById(context, bid, fid, includeRooms = false) { + // context.log(`GetFloorById: ${JSON.stringify(id)}`); + + const res = await client.submit(`g.V(bid).hasLabel("Building").in("PART_OF").hasLabel("Floor").hasId(fid)`, { bid, fid }); + const node = res.first(); + + if (node) { + return { + body: { + id: node.id, + buildingId: bid, + name: node.properties?.name[0]?.value ?? "", + comfortLevel: node.properties?.comfortLevel[0]?.value ?? "", + rooms: includeRooms ? await GetAllRoomsForFloor(context, bid, fid) : undefined + } + }; + } else { + // TODO + } +} + +async function GetAllFloors(context, bid, includeRooms = false) { + // context.log(`GetAllFloors`); + + const floors = []; + var readable = client.stream(`g.V(bid).hasLabel("Building").in("PART_OF").hasLabel("Floor")`, { bid }, { batchSize: 100 }); + + try { + for await (const result of readable) { + for (const node of result.toArray()) { + const v = { + id: node.id, + buildingId: bid, + name: node.properties?.name[0]?.value ?? "", + comfortLevel: "10", + rooms: includeRooms ? await GetAllRoomsForFloor(context, bid, node.id) : undefined + }; + floors.push(v); + } + } + } catch (err) { + console.error(err.stack); + } + + return { + body: floors + }; +} + + +async function GetAllRoomsForFloor(context, bid, fid) { + // context.log(`GetAllRoomsForFloor`); + + const rooms = []; + var readable = client.stream(`g.V(bid).hasLabel("Building").in("PART_OF").hasLabel("Floor").hasId(fid).in("PART_OF").hasLabel("Room")`, { bid, fid }, { batchSize: 100 }); + + try { + for await (const result of readable) { + for (const node of result.toArray()) { + const v = { + id: node.id, + floorId: fid, + name: node.properties?.name[0]?.value ?? "", + temp: node.properties?.temp[0]?.value ?? "", + humidity: node.properties?.humidity[0]?.value ?? "", + co2: node.properties?.co2[0]?.value ?? "", + comfortLevel: node.properties?.comfortLevel[0]?.value ?? "" + }; + rooms.push(v); + } + } + } catch (err) { + console.error(err.stack); + } + + return rooms; +} + +module.exports = async function (context, req) { + // context.log(`request: ${JSON.stringify(req)}`); + + var result = {}; + + switch (req.method) { + case "GET": + const includeRooms = "includeRooms" in req.query && req.query.includeRooms != "false"; + if (req.params.fid) { + result = await GetFloorById(context, req.params.bid, req.params.fid, includeRooms); + } else { + result = await GetAllFloors(context, req.params.bid, includeRooms); + } + break; + default: + break; + } + return result; +} \ No newline at end of file diff --git a/apps/building-comfort/functions/room/function.json b/apps/building-comfort/functions/room/function.json new file mode 100644 index 0000000..6a8a62e --- /dev/null +++ b/apps/building-comfort/functions/room/function.json @@ -0,0 +1,19 @@ +{ + "bindings": [ + { + "authLevel": "anonymous", + "type": "httpTrigger", + "direction": "in", + "name": "req", + "methods": [ + "get" + ], + "route": "building/{bid}/floor/{fid}/room/{rid?}" + }, + { + "type": "http", + "direction": "out", + "name": "$return" + } + ] +} \ No newline at end of file diff --git a/apps/building-comfort/functions/room/index.js b/apps/building-comfort/functions/room/index.js new file mode 100644 index 0000000..9c2c392 --- /dev/null +++ b/apps/building-comfort/functions/room/index.js @@ -0,0 +1,95 @@ +const gremlin = require('gremlin'); + +// Facilities Client +const authenticator = new gremlin.driver.auth.PlainTextSaslAuthenticator( + `/dbs/${process.env["FACILITIES_DB_NAME"]}/colls/${process.env["FACILITIES_CNT_NAME"]}`, process.env["FACILITIES_KEY"] +) + +const client = new gremlin.driver.Client( + process.env["FACILITIES_URL"], + { + authenticator, + traversalsource: "g", + rejectUnauthorized: true, + mimeType: "application/vnd.gremlin-v2.0+json" + } +); + +async function GetRoomById(context, bid, fid, rid) { + // context.log(`GetRoomById: ${JSON.stringify(rid)}`); + + const res = await client.submit(`g.V(bid).hasLabel("Building").in("PART_OF").hasLabel("Floor").hasId(fid).in("PART_OF").hasLabel("Room").hasId(rid)`, { bid, fid, rid }); + const node = res.first(); + + let roomName = node.properties?.name[0]?.value ?? ""; + let temp = node.properties?.temp[0]?.value ?? ""; + let humidity = node.properties?.humidity[0]?.value ?? ""; + let co2 = node.properties?.co2[0]?.value ?? ""; + let comfortLevel = Math.floor(50 + (temp - 72) + (humidity - 42) + (co2 > 500 ? (co2 - 500) / 25 : 0)); + if (node) { + return { + body: { + id: node.id, + buildingId: bid, + floorId: fid, + name: node.properties?.name[0]?.value ?? "", + temp: node.properties?.temp[0]?.value ?? "", + humidity: node.properties?.humidity[0]?.value ?? "", + co2: node.properties?.co2[0]?.value ?? "", + comfortLevel: comfortLevel + } + }; + } else { + // TODO + } +} + +async function GetAllRooms(context, bid, fid) { + // context.log(`GetAllRooms`); + + const rooms = []; + var readable = client.stream(`g.V(bid).hasLabel("Building").in("PART_OF").hasLabel("Floor").hasId(fid).in("PART_OF").hasLabel("Room")`, { bid, fid }, { batchSize: 100 }); + + try { + for await (const result of readable) { + for (const node of result.toArray()) { + const v = { + id: node.id, + buildingId: bid, + floorId: fid, + name: node.properties?.name[0]?.value ?? "", + temp: node.properties?.temp[0]?.value ?? "", + humidity: node.properties?.humidity[0]?.value ?? "", + co2: node.properties?.co2[0]?.value ?? "", + comfortLevel: node.properties?.comfortLevel[0]?.value ?? "" + }; + rooms.push(v); + } + } + } catch (err) { + console.error(err.stack); + } + + return { + body: rooms + }; +} + +module.exports = async function (context, req) { + // context.log(`request: ${JSON.stringify(req)}`); + + var result = {}; + + switch (req.method) { + case "GET": + if (req.params.rid) { + result = await GetRoomById(context, req.params.bid, req.params.fid, req.params.rid); + } else { + result = await GetAllRooms(context, req.params.bid, req.params.fid); + } + break; + default: + break; + } + return result; +} \ No newline at end of file diff --git a/apps/building-comfort/readme.md b/apps/building-comfort/readme.md index b102c5c..26c77d3 100644 --- a/apps/building-comfort/readme.md +++ b/apps/building-comfort/readme.md @@ -14,7 +14,7 @@ This folder contains the following sub-folders: - [devops](./devops/) - contains the files used to deploy and configure the Building Comfort Demo. - [azure-resources](./devops/azure-resources/) - contains the Bicep and configuration files used to deploy the Azure resources required by the Building Comfort Demo. - [data](./devops/data/) - contains the Python script used to populate the database used by the Building Comfort Demo with initial demo data. - - [reactive-graph](./devops/reactive-graph/) - contains the Drasi YAML files used to apply the Drasi components for the Building Comfort Demo. + - [drasi](./devops/drasi/) - contains the Drasi YAML files used to apply the Drasi components for the Building Comfort Demo. - [functions](./functions/) - contains Azure Functions used by the app and simulator to read and write to the source Cosmos database. ## Deploy Building Comfort Demo @@ -33,7 +33,7 @@ The `parameters.json` file can be used to customize the deployment of the Azure |Parameter|Description|Default Value| |-|-|-| |`deploymentName`|The name for the deployment. Also used as the Service Principal name, and prefix for the resulting Hosting Plan and Azure Functions app.|`rg-building-comfort-demo`| -|`cosmosAccountName`|The name for the CosmosDB account to create.|`reactive-graph-demo`| +|`cosmosAccountName`|The name for the CosmosDB account to create.|`drasi-demo`| |`storageAccountName`|The name for the Storage Account used to host the ReactJS app.|`rgbuildingcomfort`| |`eventGridTopicName`|The name of the Event Grid Topic to create.|`rg-building-comfort-demo`| @@ -91,7 +91,7 @@ This step is similar to the [self-hosting instructions](https://project-drasi-do #### Deploy the sources -From the `devops/reactive-graph` subfolder, edit the `source-facilities.yaml` file to specify your Cosmos DB instance: +From the `devops/drasi` subfolder, edit the `source-facilities.yaml` file to specify your Cosmos DB instance: - `SourceAccountEndpoint` with the primary connection string - `SourceKey` with the primary key, same as the `cosmosPassword` in `config.py` @@ -111,7 +111,7 @@ kubectl apply -f source-facilities.yaml #### Deploy the queries -From the `devops/reactive-graph` subfolder, use `kubectl` to deploy the continuous queries: +From the `devops/drasi` subfolder, use `kubectl` to deploy the continuous queries: ```bash kubectl apply -f query-alert.yaml @@ -121,7 +121,7 @@ kubectl apply -f query-ui.yaml #### Deploy the reactions -From the `devops/reactive-graph` subfolder,ppply the Reaction yaml files with `kubectl` to your AKS cluster running Drasi: +From the `devops/drasi` subfolder,ppply the Reaction yaml files with `kubectl` to your AKS cluster running Drasi: ```bash kubectl apply -f reaction-signalr.yaml From f52a223bf6008036f11f16bbcd5f2622a8cef6eb Mon Sep 17 00:00:00 2001 From: "Ruokun (Tommy) Niu" Date: Wed, 28 Aug 2024 09:18:27 -0700 Subject: [PATCH 6/8] Update apps/building-comfort/readme.md Co-authored-by: Daniel Gerlag --- apps/building-comfort/readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/building-comfort/readme.md b/apps/building-comfort/readme.md index 26c77d3..0ee24c6 100644 --- a/apps/building-comfort/readme.md +++ b/apps/building-comfort/readme.md @@ -121,7 +121,7 @@ kubectl apply -f query-ui.yaml #### Deploy the reactions -From the `devops/drasi` subfolder,ppply the Reaction yaml files with `kubectl` to your AKS cluster running Drasi: +From the `devops/drasi` subfolder, apply the Reaction yaml files with `kubectl` to your AKS cluster running Drasi: ```bash kubectl apply -f reaction-signalr.yaml From 193215ceb76799e3f1990dc329213bb1502fb73e Mon Sep 17 00:00:00 2001 From: "Ruokun (Tommy) Niu" Date: Thu, 12 Sep 2024 09:06:21 -0700 Subject: [PATCH 7/8] Update apps/building-comfort/readme.md Co-authored-by: Daniel Gerlag --- apps/building-comfort/readme.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/building-comfort/readme.md b/apps/building-comfort/readme.md index 0ee24c6..dd4e113 100644 --- a/apps/building-comfort/readme.md +++ b/apps/building-comfort/readme.md @@ -121,7 +121,7 @@ kubectl apply -f query-ui.yaml #### Deploy the reactions -From the `devops/drasi` subfolder, apply the Reaction yaml files with `kubectl` to your AKS cluster running Drasi: +From the `devops/drasi` subfolder, apply the Reaction yaml files with the `drasi` CLI to your AKS cluster running Drasi: ```bash kubectl apply -f reaction-signalr.yaml From f9336c0dc6cb9946ec9f89a6ab50538108aa176f Mon Sep 17 00:00:00 2001 From: ruokun-niu Date: Thu, 12 Sep 2024 09:15:32 -0700 Subject: [PATCH 8/8] readme update --- apps/react/readme.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/react/readme.md b/apps/react/readme.md index 7692bee..d3b68ec 100644 --- a/apps/react/readme.md +++ b/apps/react/readme.md @@ -31,9 +31,9 @@ function App() { item.EmployeeName + item.IncidentDescription} - template={ItemTemplate} /> + onMessage={ItemTemplate} />