Merge branch 'pending_changes' into dev_environment

This commit is contained in:
Carter 2026-07-07 15:56:31 -06:00
commit 62cc25d775
17 changed files with 581 additions and 215 deletions

199
package-lock.json generated
View file

@ -146,9 +146,9 @@
} }
}, },
"node_modules/@babel/compat-data": { "node_modules/@babel/compat-data": {
"version": "7.29.0", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -156,21 +156,21 @@
} }
}, },
"node_modules/@babel/core": { "node_modules/@babel/core": {
"version": "7.29.0", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.29.0", "@babel/code-frame": "^7.29.7",
"@babel/generator": "^7.29.0", "@babel/generator": "^7.29.7",
"@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-compilation-targets": "^7.29.7",
"@babel/helper-module-transforms": "^7.28.6", "@babel/helper-module-transforms": "^7.29.7",
"@babel/helpers": "^7.28.6", "@babel/helpers": "^7.29.7",
"@babel/parser": "^7.29.0", "@babel/parser": "^7.29.7",
"@babel/template": "^7.28.6", "@babel/template": "^7.29.7",
"@babel/traverse": "^7.29.0", "@babel/traverse": "^7.29.7",
"@babel/types": "^7.29.0", "@babel/types": "^7.29.7",
"@jridgewell/remapping": "^2.3.5", "@jridgewell/remapping": "^2.3.5",
"convert-source-map": "^2.0.0", "convert-source-map": "^2.0.0",
"debug": "^4.1.0", "debug": "^4.1.0",
@ -233,14 +233,14 @@
} }
}, },
"node_modules/@babel/helper-compilation-targets": { "node_modules/@babel/helper-compilation-targets": {
"version": "7.28.6", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/compat-data": "^7.28.6", "@babel/compat-data": "^7.29.7",
"@babel/helper-validator-option": "^7.27.1", "@babel/helper-validator-option": "^7.29.7",
"browserslist": "^4.24.0", "browserslist": "^4.24.0",
"lru-cache": "^5.1.1", "lru-cache": "^5.1.1",
"semver": "^6.3.1" "semver": "^6.3.1"
@ -482,9 +482,9 @@
} }
}, },
"node_modules/@babel/helper-validator-option": { "node_modules/@babel/helper-validator-option": {
"version": "7.27.1", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@ -507,14 +507,14 @@
} }
}, },
"node_modules/@babel/helpers": { "node_modules/@babel/helpers": {
"version": "7.28.6", "version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@babel/template": "^7.28.6", "@babel/template": "^7.29.7",
"@babel/types": "^7.28.6" "@babel/types": "^7.29.7"
}, },
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
@ -6986,9 +6986,9 @@
} }
}, },
"node_modules/axios": { "node_modules/axios": {
"version": "1.17.0", "version": "1.18.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.17.0.tgz", "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.1.tgz",
"integrity": "sha512-J8SwNxprqqpbfenehxWYXE7CW+wM1BB4w3+N+g+/Wx40xM4rsLrfPmHHxSWIxJLYDgSY/HqlFPIYb2/S3rxafw==", "integrity": "sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"follow-redirects": "^1.16.0", "follow-redirects": "^1.16.0",
@ -6998,16 +6998,16 @@
} }
}, },
"node_modules/axios/node_modules/form-data": { "node_modules/axios/node_modules/form-data": {
"version": "4.0.5", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.12" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
"node": ">= 6" "node": ">= 6"
@ -8357,6 +8357,25 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/es-abstract-get": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/es-abstract-get/-/es-abstract-get-1.0.0.tgz",
"integrity": "sha512-6PMWXpdhshVvFp+FoWYs1EvG1Nj0tvk0dZM+XcK0xMEM1czRVcP6ohqPWHy6qPagSpC8j4+p89WXlT+xXJs/fg==",
"dev": true,
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.2",
"is-callable": "^1.2.7",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/es-cookie": { "node_modules/es-cookie": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz", "resolved": "https://registry.npmjs.org/es-cookie/-/es-cookie-1.3.2.tgz",
@ -8416,15 +8435,18 @@
} }
}, },
"node_modules/es-to-primitive": { "node_modules/es-to-primitive": {
"version": "1.3.0", "version": "1.3.4",
"resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.4.tgz",
"integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", "integrity": "sha512-yPDz7wqpg1/mmHLmS3tcfTfbw5f1eryXvyghYBffGdERwe+mV7ZcWzTR8LR17Kvqt3qfPurjlonmnq3MKXIOXw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"es-abstract-get": "^1.0.0",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"is-callable": "^1.2.7", "is-callable": "^1.2.7",
"is-date-object": "^1.0.5", "is-date-object": "^1.1.0",
"is-symbol": "^1.0.4" "is-symbol": "^1.1.1"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -8764,9 +8786,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.2", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", "integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -8977,15 +8999,15 @@
} }
}, },
"node_modules/form-data": { "node_modules/form-data": {
"version": "3.0.4", "version": "3.0.5",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.4.tgz", "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.5.tgz",
"integrity": "sha512-f0cRzm6dkyVYV3nPoooP8XlccPQukegwhAnpoLcXy+X+A8KfpGOoXwDr9FLZd3wzgLaBGQBE3lY93Zm/i1JvIQ==", "integrity": "sha512-j23EibVLnp4zNXGW7LjryXYa2X6U/M96yoOX+ybZxwkYajdxRNEqYY3zhh7y0i6kfISKS2jr+EJq1YTUDEv5+w==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"asynckit": "^0.4.0", "asynckit": "^0.4.0",
"combined-stream": "^1.0.8", "combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0", "es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2", "hasown": "^2.0.4",
"mime-types": "^2.1.35" "mime-types": "^2.1.35"
}, },
"engines": { "engines": {
@ -9060,18 +9082,21 @@
} }
}, },
"node_modules/function.prototype.name": { "node_modules/function.prototype.name": {
"version": "1.1.8", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.2.0.tgz",
"integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", "integrity": "sha512-jObKIik1P2QjPHP5nz5BaOtUlfgS0fWo8IUByNXkM+o+02sJOi94em77GwJKQSJ3gfPHdgzLNrHc1uokV4P/ew==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"call-bind": "^1.0.8", "call-bind": "^1.0.9",
"call-bound": "^1.0.3", "call-bound": "^1.0.4",
"define-properties": "^1.2.1", "es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"functions-have-names": "^1.2.3", "functions-have-names": "^1.2.3",
"hasown": "^2.0.2", "has-property-descriptors": "^1.0.2",
"is-callable": "^1.2.7" "hasown": "^2.0.4",
"is-callable": "^1.2.7",
"is-document.all": "^1.0.0"
}, },
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -9316,9 +9341,9 @@
} }
}, },
"node_modules/glob/node_modules/brace-expansion": { "node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.6", "version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@ -9535,9 +9560,9 @@
} }
}, },
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.4",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"function-bind": "^1.1.2" "function-bind": "^1.1.2"
@ -9913,6 +9938,22 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-document.all": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/is-document.all/-/is-document.all-1.0.0.tgz",
"integrity": "sha512-+XSoyS05OdBbhFuELhgTCpFNHkpBOJqtsZfUFFpe5QTw+9Sjbh8zitxhQkYAo6wV7e1Vb8cAPvpCk9jGam/82g==",
"dev": true,
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/is-extendable": { "node_modules/is-extendable": {
"version": "0.1.1", "version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
@ -10356,10 +10397,20 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.1", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.0.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", "integrity": "sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==",
"dev": true, "dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"argparse": "^2.0.1" "argparse": "^2.0.1"
@ -11631,9 +11682,9 @@
} }
}, },
"node_modules/postcss": { "node_modules/postcss": {
"version": "8.5.15", "version": "8.5.16",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -11666,9 +11717,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/postcss/node_modules/nanoid": { "node_modules/postcss/node_modules/nanoid": {
"version": "3.3.12", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
"dev": true, "dev": true,
"funding": [ "funding": [
{ {
@ -12903,9 +12954,9 @@
} }
}, },
"node_modules/serialize-javascript": { "node_modules/serialize-javascript": {
"version": "7.0.5", "version": "7.0.7",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.7.tgz",
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "integrity": "sha512-YAy8Od6KV+uuwUuU50np8fGB/Aues6Y0nAhA9y/hId74PlKUcme4pXcBD46NWKr1Q4osN/iseZ17YqO1XfmI8g==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "engines": {

View file

@ -8,7 +8,7 @@ import { Bin } from "models";
import moment, { Moment } from "moment"; import moment, { Moment } from "moment";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import { quack } from "protobuf-ts/quack"; import { quack } from "protobuf-ts/quack";
import { useComponentAPI } from "providers"; import { useBinAPI, useComponentAPI } from "providers";
import React, { useEffect, useRef, useState } from "react"; import React, { useEffect, useRef, useState } from "react";
const CHECKPOINT_COUNT = 10; const CHECKPOINT_COUNT = 10;
@ -16,6 +16,7 @@ const CHECKPOINT_COUNT = 10;
interface StatusCheckpoint { interface StatusCheckpoint {
time: Moment; time: Moment;
timeString: string; timeString: string;
statusBushels: number;
cables: pond.GrainCable[]; cables: pond.GrainCable[];
fans: pond.BinFan[]; fans: pond.BinFan[];
heaters: pond.BinHeater[]; heaters: pond.BinHeater[];
@ -90,6 +91,103 @@ function upsertReading(
} }
} }
/**
* A trimmed record of a single component-settings history entry: just the timestamp and the
* top-node value (`grain_filled_to`) that was in effect from that point forward.
*/
type TopNodeHistoryEntry = {
timestamp: Moment;
topNode: number;
};
function buildTopNodeHistory(
history: pond.ComponentHistory[]
): TopNodeHistoryEntry[] {
return history
.filter(h => h.component && h.component.grainFilledTo > 0 && h.timestamp)
.map(h => ({
timestamp: moment(h.timestamp),
topNode: h.component ? h.component.grainFilledTo : 0,
}))
.sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf());
}
function topNodeAtTime(
history: TopNodeHistoryEntry[],
checkpointTime: Moment
): number | undefined {
if (history.length === 0) return undefined;
const t = checkpointTime.valueOf();
let lo = 0;
let hi = history.length - 1;
let result: number | undefined = undefined;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (history[mid].timestamp.valueOf() <= t) {
result = history[mid].topNode;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}
/**
* A single recorded bushel estimate with its timestamp, sourced from bin object measurements.
*/
type BushelEntry = {
timestamp: Moment;
bushels: number;
};
function buildBushelHistory(
response: pond.ListObjectMeasurementsResponse
): BushelEntry[] {
const entries: BushelEntry[] = [];
response.measurements.forEach(um => {
const isBushelType =
um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_LIDAR ||
um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_CABLE ||
um.measurementType === quack.MeasurementType.MEASUREMENT_TYPE_BUSHEL_LIBRACART;
if (!isBushelType) return;
um.timestamps.forEach((ts, i) => {
const valueArray = um.values[i];
if (!ts || !valueArray || valueArray.error || valueArray.values.length === 0) return;
entries.push({
timestamp: moment(ts),
bushels: valueArray.values[0],
});
});
});
return entries.sort((a, b) => a.timestamp.valueOf() - b.timestamp.valueOf());
}
function bushelsAtTime(
history: BushelEntry[],
checkpointTime: Moment
): number | undefined {
if (history.length === 0) return undefined;
const t = checkpointTime.valueOf();
let lo = 0;
let hi = history.length - 1;
let result: number | undefined = undefined;
while (lo <= hi) {
const mid = (lo + hi) >>> 1;
if (history[mid].timestamp.valueOf() <= t) {
result = history[mid].bushels;
lo = mid + 1;
} else {
hi = mid - 1;
}
}
return result;
}
/** /**
* Produces `count` wall-clock times evenly spaced from `start` through `end` (inclusive). * Produces `count` wall-clock times evenly spaced from `start` through `end` (inclusive).
* These are the playback frames not derived from measurement data. * These are the playback frames not derived from measurement data.
@ -360,6 +458,12 @@ function buildStatusCheckpoints(
cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[], cableMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[], fanMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
heaterMeasurementResponses: pond.SampleUnitMeasurementsResponse[], heaterMeasurementResponses: pond.SampleUnitMeasurementsResponse[],
/** Sorted top-node history per cable key. Pass an empty Map when not needed. */
topNodeHistories: Map<string, TopNodeHistoryEntry[]>,
/** Sorted bushel history derived from bin object measurements. */
bushelHistory: BushelEntry[],
/** Fallback bushel count used when no measurement precedes a checkpoint. */
fallbackBushels: number,
start: Moment, start: Moment,
end: Moment, end: Moment,
count: number = CHECKPOINT_COUNT count: number = CHECKPOINT_COUNT
@ -416,7 +520,19 @@ function buildStatusCheckpoints(
const bucketReadings = cableBucket.get(key) ?? {}; const bucketReadings = cableBucket.get(key) ?? {};
const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings); const merged = mergeCarriedReadings(carriedByCable.get(key)!, bucketReadings);
carriedByCable.set(key, merged); carriedByCable.set(key, merged);
return buildGrainCableFromReadings(template, merged, time); const cable = buildGrainCableFromReadings(template, merged, time);
// Apply the most recent top-node setting that was in effect at this checkpoint time.
// Falls back to the template's current top_node when no history entry precedes this time.
const history = topNodeHistories.get(key);
if (history) {
const tn = topNodeAtTime(history, time);
if (tn !== undefined) {
cable.topNode = tn;
}
}
return cable;
}); });
// Build fans // Build fans
@ -437,7 +553,12 @@ function buildStatusCheckpoints(
return buildBinHeaterFromState(template, merged); return buildBinHeaterFromState(template, merged);
}); });
return { time, cables, fans, heaters, timeString: time.toISOString() }; // Resolve bushels: binary search gives the most recent recorded value at or before this
// checkpoint (carry-forward is implicit in the search). Falls back to the current live
// status bushels when the playback range predates all recorded measurements.
const statusBushels = bushelsAtTime(bushelHistory, time) ?? fallbackBushels;
return { time, cables, fans, heaters, timeString: time.toISOString(), statusBushels };
}).filter(checkpoint => }).filter(checkpoint =>
// Keep checkpoints that have at least some cable data // Keep checkpoints that have at least some cable data
checkpoint.cables.some(cable => checkpoint.cables.some(cable =>
@ -471,6 +592,7 @@ export default function BinPlayer(props: Props) {
const { bin, componentDevices, setBin } = props; const { bin, componentDevices, setBin } = props;
const isMobile = useMobile(); const isMobile = useMobile();
const componentAPI = useComponentAPI(); const componentAPI = useComponentAPI();
const binAPI = useBinAPI();
const [dateSelect, setDateSelect] = useState<number>(0); const [dateSelect, setDateSelect] = useState<number>(0);
const [startDate, setStartDate] = useState<Moment>(moment().subtract(1, 'week')); const [startDate, setStartDate] = useState<Moment>(moment().subtract(1, 'week'));
const [endDate, setEndDate] = useState<Moment>(moment); const [endDate, setEndDate] = useState<Moment>(moment);
@ -529,6 +651,17 @@ export default function BinPlayer(props: Props) {
return componentAPI.sampleUnitMeasurements(deviceID, cable.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); return componentAPI.sampleUnitMeasurements(deviceID, cable.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true);
}); });
// Cable top-node history requests — one per cable, covering the full playback range.
const cableHistoryRequests = bin.status.grainCables.map(cable => {
const deviceID = componentDevices.get(cable.key) ?? 0;
return componentAPI.listHistoryBetween(
deviceID,
cable.key,
startDate.toISOString(),
endDate.toISOString()
);
});
// Fan requests // Fan requests
const fanSampleRequests = bin.status.fans.map(fan => { const fanSampleRequests = bin.status.fans.map(fan => {
const deviceID = componentDevices.get(fan.key) ?? 0; const deviceID = componentDevices.get(fan.key) ?? 0;
@ -541,12 +674,27 @@ export default function BinPlayer(props: Props) {
return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true); return componentAPI.sampleUnitMeasurements(deviceID, heater.key, startDate, endDate, 100, undefined, undefined, undefined, undefined, true);
}); });
const [cableResponses, fanResponses, heaterResponses] = await Promise.all([ const binMeasurementsRequest = binAPI.listBinMeasurements(bin.key(), startDate, endDate, 0, 0, 'asc');
const [cableResponses, fanResponses, heaterResponses, cableHistoryResponses, binMeasurementsResponse] = await Promise.all([
Promise.all(cableSampleRequests), Promise.all(cableSampleRequests),
Promise.all(fanSampleRequests), Promise.all(fanSampleRequests),
Promise.all(heaterSampleRequests), Promise.all(heaterSampleRequests),
Promise.all(cableHistoryRequests),
binMeasurementsRequest,
]); ]);
// Build a per-cable sorted top-node history map for use during checkpoint construction.
const topNodeHistories = new Map<string, TopNodeHistoryEntry[]>();
bin.status.grainCables.forEach((cable, i) => {
const historyResp = cableHistoryResponses[i];
const entries = buildTopNodeHistory(historyResp?.data?.history ?? []);
topNodeHistories.set(cable.key, entries);
});
// Build sorted bushel history from bin object measurements.
const bushelHistory = buildBushelHistory(binMeasurementsResponse.data);
const checkpoints = buildStatusCheckpoints( const checkpoints = buildStatusCheckpoints(
bin.status.grainCables, bin.status.grainCables,
bin.status.fans, bin.status.fans,
@ -554,6 +702,9 @@ export default function BinPlayer(props: Props) {
cableResponses.map(r => r.data), cableResponses.map(r => r.data),
fanResponses.map(r => r.data), fanResponses.map(r => r.data),
heaterResponses.map(r => r.data), heaterResponses.map(r => r.data),
topNodeHistories,
bushelHistory,
bin.status.grainBushels,
startDate, startDate,
endDate, endDate,
checkpointCountFromRange(startDate, endDate) checkpointCountFromRange(startDate, endDate)
@ -574,6 +725,7 @@ export default function BinPlayer(props: Props) {
newBin.status.grainCables = checkpoints[i].cables; newBin.status.grainCables = checkpoints[i].cables;
newBin.status.fans = checkpoints[i].fans; newBin.status.fans = checkpoints[i].fans;
newBin.status.heaters = checkpoints[i].heaters; newBin.status.heaters = checkpoints[i].heaters;
newBin.status.grainBushels = checkpoints[i].statusBushels;
setBin(newBin); setBin(newBin);
setCurrentCheckpointTime(checkpoints[i].time); setCurrentCheckpointTime(checkpoints[i].time);
@ -715,4 +867,4 @@ export default function BinPlayer(props: Props) {
</Box> </Box>
</React.Fragment> </React.Fragment>
); );
} }

View file

@ -312,7 +312,7 @@ export default function BinTableView(props: Props) {
<Box display="flex" alignItems="center" gap={1}> <Box display="flex" alignItems="center" gap={1}>
<Button <Button
variant="contained" variant="contained"
sx={{ background: theme.palette.background.default}} sx={{ background: theme.palette.background.default, color: theme.palette.text.primary}}
onClick={() => { onClick={() => {
setOpenExpandedCables(true) setOpenExpandedCables(true)
}}> }}>

View file

@ -0,0 +1,46 @@
import { Chip, CircularProgress, Tooltip } from "@mui/material";
import { CheckCircleOutline } from "@mui/icons-material";
interface Props {
pending: boolean;
accepted: boolean;
size?: "small" | "medium";
}
/**
* Capsule tag shown alongside the other status chips while changes are
* waiting to be synced to a device or component. Shows a small loading
* circle while pending and a checkmark once the change is accepted.
* Derive the flags with usePendingChanges.
*/
export default function PendingChangesChip(props: Props) {
const { pending, accepted, size } = props;
if (pending) {
return (
<Tooltip title="Your most recent changes will be synced to the device as soon as possible">
<Chip
variant="outlined"
label="Pending changes"
icon={<CircularProgress size={14} thickness={5} color="inherit" />}
size={size}
/>
</Tooltip>
);
}
if (accepted) {
return (
<Tooltip title="The device has accepted your most recent changes">
<Chip
variant="outlined"
label="Settings synced"
icon={<CheckCircleOutline style={{ color: "var(--status-ok)" }} />}
size={size}
/>
</Tooltip>
);
}
return null;
}

View file

@ -0,0 +1,27 @@
import { CircularProgress, Grid2 as Grid } from "@mui/material";
import { CheckCircleOutline } from "@mui/icons-material";
interface Props {
pending: boolean;
accepted: boolean;
text: string;
}
/**
* Small inline status line used inside cards: a loading circle beside the
* caption while changes are pending, and a checkmark once they are accepted.
* Derive the flags with usePendingChanges (or per-item tracking like
* InteractionsOverview does).
*/
export default function PendingChangesIndicator(props: Props) {
const { pending, accepted, text } = props;
if (!pending && !accepted) return null;
return (
<Grid container spacing={0.5} alignItems="center">
{pending && <CircularProgress size={10} thickness={5} color="inherit" />}
{accepted && <CheckCircleOutline sx={{ fontSize: 13, color: "var(--status-ok)" }} />}
<Grid>{text}</Grid>
</Grid>
);
}

View file

@ -1,20 +0,0 @@
import { Chip, Tooltip } from "@mui/material";
import { getStatusHelper } from "pbHelpers/Status";
interface Props {
status: string;
size?: "small" | "medium";
}
export default function StatusChip(props: Props) {
const { status, size } = props;
const statusHelper = getStatusHelper(status);
const icon = statusHelper.icon;
const description = statusHelper.description;
return icon !== null ? (
<Tooltip title="Your most recent changes will be synced to the device as soon as possible">
<Chip variant="outlined" label={description} icon={icon} size={size} />
</Tooltip>
) : null;
}

View file

@ -13,11 +13,13 @@ import {
} from "@mui/material"; } from "@mui/material";
import Grid from '@mui/material/Grid2'; import Grid from '@mui/material/Grid2';
import EventBlocker from "common/EventBlocker"; import EventBlocker from "common/EventBlocker";
import PendingChangesIndicator from "common/PendingChangesIndicator";
import MeasurementSummary from "component/MeasurementSummary"; import MeasurementSummary from "component/MeasurementSummary";
import { useComponentAPI, useSnackbar, useThemeType } from "hooks"; import { useComponentAPI, usePendingChanges, useSnackbar, useThemeType } from "hooks";
import InteractionsOverview from "interactions/InteractionsOverview"; import InteractionsOverview from "interactions/InteractionsOverview";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import { Component, Device, Interaction } from "models"; import { Component, Device, Interaction } from "models";
import moment from "moment";
import { getFriendlyAddressTypeName, getHumanReadableAddress } from "pbHelpers/AddressType"; import { getFriendlyAddressTypeName, getHumanReadableAddress } from "pbHelpers/AddressType";
import { controllerModeLabel } from "pbHelpers/Component"; import { controllerModeLabel } from "pbHelpers/Component";
import { import {
@ -121,6 +123,9 @@ export default function ComponentCard(props: Props) {
const location = useLocation() const location = useLocation()
const [sensors, setSensors] = useState<Sensor[]>([]); const [sensors, setSensors] = useState<Sensor[]>([]);
const [{ user, showErrors, as }] = useGlobalState(); const [{ user, showErrors, as }] = useGlobalState();
const { pending: pendingChanges, accepted: changesAccepted } = usePendingChanges(
component.status.synced
);
const updateControllerState = (checked: boolean) => { const updateControllerState = (checked: boolean) => {
let updatedComponent = cloneDeep(component); let updatedComponent = cloneDeep(component);
@ -133,6 +138,8 @@ export default function ComponentCard(props: Props) {
.update(device.id(), updatedComponent.settings, undefined, undefined, as) .update(device.id(), updatedComponent.settings, undefined, undefined, as)
.then(() => { .then(() => {
success(component.name() + "'s mode was set to " + describe); success(component.name() + "'s mode was set to " + describe);
updatedComponent.status.synced = false;
updatedComponent.status.lastUpdate = moment().toISOString();
refreshCallback(updatedComponent); refreshCallback(updatedComponent);
}) })
.catch(() => { .catch(() => {
@ -287,19 +294,34 @@ export default function ComponentCard(props: Props) {
className={classes.cardHeader} className={classes.cardHeader}
titleTypographyProps={{ variant: "subtitle1" }} titleTypographyProps={{ variant: "subtitle1" }}
subheader={ subheader={
// !newStructure ? ( <React.Fragment>
// <MeasurementSummary {(pendingChanges || changesAccepted) && (
// component={component} <Typography variant="caption" color="textSecondary" component="div">
// reading={component.status.lastMeasurement} <PendingChangesIndicator
// dense pending={pendingChanges}
// /> accepted={changesAccepted}
// ) : ( text={
changesAccepted
? "Pending change accepted"
: "Pending " + moment(component.status.lastUpdate).fromNow()
}
/>
</Typography>
)}
{/* !newStructure ? (
<MeasurementSummary
component={component}
reading={component.status.lastMeasurement}
dense
/>
) : ( */}
<UnitMeasurementSummary <UnitMeasurementSummary
component={component} component={component}
reading={UnitMeasurement.convertLastMeasurement(measurements)} reading={UnitMeasurement.convertLastMeasurement(measurements)}
//dense //dense
/> />
// ) {/* ) */}
</React.Fragment>
} }
action={ action={
<EventBlocker> <EventBlocker>

View file

@ -1,9 +1,9 @@
import { Chip, Grid2 as Grid, Theme, Tooltip, Skeleton } from "@mui/material"; import { Chip, Grid2 as Grid, Theme, Tooltip, Skeleton } from "@mui/material";
import { DataUsage, SimCard, Launch, DeviceHub } from "@mui/icons-material"; import { DataUsage, SimCard, Launch, DeviceHub } from "@mui/icons-material";
import StatusChip from "common/StatusChip"; import PendingChangesChip from "common/PendingChangesChip";
import DeviceTags from "device/DeviceTags"; import DeviceTags from "device/DeviceTags";
import VersionChip from "device/VersionChip"; import VersionChip from "device/VersionChip";
import { useSnackbar, useWidth, usePrevious } from "hooks"; import { useSnackbar, useWidth, usePendingChanges, usePrevious } from "hooks";
import { Device, latestFirmwareVersion, Component } from "models"; import { Device, latestFirmwareVersion, Component } from "models";
import moment from "moment"; import moment from "moment";
import { describeConnectivity } from "pbHelpers/Connectivity"; import { describeConnectivity } from "pbHelpers/Connectivity";
@ -54,6 +54,10 @@ export default function DeviceOverview(props: Props) {
// const match = useRouteMatch<MatchParams>(); // const match = useRouteMatch<MatchParams>();
const [powerComponent, setPowerComponent] = useState<Component | undefined | null>(); const [powerComponent, setPowerComponent] = useState<Component | undefined | null>();
const [modemComponent, setModemComponent] = useState<Component | undefined | null>(); const [modemComponent, setModemComponent] = useState<Component | undefined | null>();
const { pending: pendingChanges, accepted: changesAccepted } = usePendingChanges(
device.status.synced,
!loading && device.id() > 0
);
// const [hologramDialog, setHologramDialog] = useState(false); // const [hologramDialog, setHologramDialog] = useState(false);
useEffect(() => { useEffect(() => {
@ -228,9 +232,9 @@ export default function DeviceOverview(props: Props) {
</Tooltip> </Tooltip>
</Grid> </Grid>
)} )}
{!device.status.synced && ( {(pendingChanges || changesAccepted) && (
<Grid> <Grid>
<StatusChip status="pending" /> <PendingChangesChip pending={pendingChanges} accepted={changesAccepted} />
</Grid> </Grid>
)} )}
{device.settings.needsBusControl && ( {device.settings.needsBusControl && (

View file

@ -26,6 +26,7 @@ export {
export * from "./useDebounce"; export * from "./useDebounce";
// export * from "./useForceUpdate"; // export * from "./useForceUpdate";
// export * from "./useInterval"; // export * from "./useInterval";
export * from "./usePendingChanges";
export * from "./usePrevious"; export * from "./usePrevious";
export * from "./useThemeType"; export * from "./useThemeType";
export * from "./useWidth"; export * from "./useWidth";

View file

@ -0,0 +1,31 @@
import { useEffect, useRef, useState } from "react";
/**
* Tracks a synced flag and reports whether changes are pending or were
* accepted while mounted (a false -> true transition, e.g. from a websocket
* status update). Pass enabled=false until real data has loaded so the
* initial placeholder -> loaded flip is not mistaken for an accepted change.
*/
export function usePendingChanges(synced: boolean, enabled: boolean = true) {
const prevSynced = useRef<boolean | undefined>(undefined);
const [accepted, setAccepted] = useState(false);
useEffect(() => {
if (!enabled) {
prevSynced.current = undefined;
setAccepted(false);
return;
}
if (prevSynced.current === false && synced) {
setAccepted(true);
} else if (!synced) {
setAccepted(false);
}
prevSynced.current = synced;
}, [synced, enabled]);
return {
pending: enabled && !synced,
accepted: enabled && synced && accepted
};
}

View file

@ -4,7 +4,6 @@ import {
CardActions, CardActions,
CardContent, CardContent,
CardHeader, CardHeader,
CircularProgress,
darken, darken,
Grid2 as Grid, Grid2 as Grid,
IconButton, IconButton,
@ -15,7 +14,8 @@ import {
Tooltip, Tooltip,
Typography Typography
} from "@mui/material"; } from "@mui/material";
import { CheckCircleOutline, Settings } from "@mui/icons-material"; import { Settings } from "@mui/icons-material";
import PendingChangesIndicator from "common/PendingChangesIndicator";
import { useInteractionsAPI, usePrevious, useSnackbar } from "hooks"; import { useInteractionsAPI, usePrevious, useSnackbar } from "hooks";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
import { Component, Device, Interaction } from "models"; import { Component, Device, Interaction } from "models";
@ -329,17 +329,11 @@ export default function InteractionsOverview(props: Props) {
title={interactionResultText(interaction, sink)} title={interactionResultText(interaction, sink)}
subheader={ subheader={
statusText ? ( statusText ? (
<Grid container spacing={0.5} alignItems="center"> <PendingChangesIndicator
{isPending && <CircularProgress size={10} thickness={5} color="inherit" />} pending={isPending}
{isAccepted && ( accepted={isAccepted}
<CheckCircleOutline text={statusText}
sx={{ fontSize: 13, color: "var(--status-ok)" }} />
/>
)}
<Grid>
{statusText}
</Grid>
</Grid>
) : ""} ) : ""}
subheaderTypographyProps={{ variant: "caption" }} subheaderTypographyProps={{ variant: "caption" }}
action={action} action={action}

View file

@ -19,7 +19,7 @@ import { DeviceAvailabilityMap, FindAvailablePositions, OffsetAvailabilityMap }
import ComponentCard from "component/ComponentCard"; import ComponentCard from "component/ComponentCard";
import { sameComponentID, sortComponents } from "pbHelpers/Component"; import { sameComponentID, sortComponents } from "pbHelpers/Component";
import { isController } from "pbHelpers/ComponentType"; import { isController } from "pbHelpers/ComponentType";
import { or } from "utils"; import { getTimestampMillis, isStaleStatusUpdate, or } from "utils";
import ComponentDiagnostics from "component/ComponentDiagnostics"; import ComponentDiagnostics from "component/ComponentDiagnostics";
import DeviceWizard from "device/DeviceWizard"; import DeviceWizard from "device/DeviceWizard";
import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents"; import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents";
@ -27,12 +27,6 @@ import { useWebSocket } from "hooks/useWebSocket";
type TimestampedReading = { timestamp?: string }; type TimestampedReading = { timestamp?: string };
function getTimestampMillis(timestamp?: string): number | undefined {
if (!timestamp) return undefined;
const parsed = Date.parse(timestamp);
return Number.isNaN(parsed) ? undefined : parsed;
}
function pickNewestReading<T extends TimestampedReading>( function pickNewestReading<T extends TimestampedReading>(
current: T | null | undefined, current: T | null | undefined,
incoming: T | null | undefined incoming: T | null | undefined
@ -270,6 +264,10 @@ export default function DevicePage() {
if (!data) return; if (!data) return;
const { key, component } = data; const { key, component } = data;
setComponents((prev) => { setComponents((prev) => {
const existing = prev.get(key);
if (existing && isStaleStatusUpdate(existing.status, component.status)) {
return prev;
}
const updated = new Map(prev); const updated = new Map(prev);
updated.set(key, component); updated.set(key, component);
return updated; return updated;
@ -315,17 +313,7 @@ export default function DevicePage() {
return [...prev, interaction]; return [...prev, interaction];
} }
const existing = prev[index]; const existing = prev[index];
const existingLastUpdate = getTimestampMillis(existing.status.lastUpdate); if (isStaleStatusUpdate(existing.status, interaction.status)) {
const incomingLastUpdate = getTimestampMillis(interaction.status.lastUpdate);
const incomingLastSynced = getTimestampMillis(interaction.status.lastSynced);
if (existingLastUpdate !== undefined && incomingLastUpdate !== undefined &&
existingLastUpdate > incomingLastUpdate) {
return prev;
}
if (!existing.status.synced && interaction.status.synced &&
incomingLastUpdate !== undefined &&
existingLastUpdate === incomingLastUpdate &&
(incomingLastSynced === undefined || incomingLastSynced < incomingLastUpdate)) {
return prev; return prev;
} }
const updated = [...prev]; const updated = [...prev];

View file

@ -4,7 +4,7 @@ import { NextMeasurementChip } from "common/NextMeasurementChip";
import ResponsiveTable, { Column } from "common/ResponsiveTable"; import ResponsiveTable, { Column } from "common/ResponsiveTable";
// import { getTableIcons } from "common/ResponsiveTable"; // import { getTableIcons } from "common/ResponsiveTable";
import SmartBreadcrumb from "common/SmartBreadcrumb"; import SmartBreadcrumb from "common/SmartBreadcrumb";
import StatusChip from "common/StatusChip"; import PendingChangesChip from "common/PendingChangesChip";
import { GetDefaultDateRange } from "common/time/DateRange"; import { GetDefaultDateRange } from "common/time/DateRange";
import ComponentActions from "component/ComponentActions"; import ComponentActions from "component/ComponentActions";
import ComponentChart from "component/ComponentChart"; import ComponentChart from "component/ComponentChart";
@ -16,11 +16,14 @@ import {
useComponentAPI, useComponentAPI,
useDeviceAPI, useDeviceAPI,
useGroupAPI, useGroupAPI,
useHTTP,
useInteractionsAPI, useInteractionsAPI,
usePendingChanges,
usePrevious, usePrevious,
useSnackbar, useSnackbar,
useUserAPI useUserAPI
} from "hooks"; } from "hooks";
import { useWebSocket } from "hooks/useWebSocket";
import InteractionChip from "interactions/InteractionChip"; import InteractionChip from "interactions/InteractionChip";
import InteractionSettings from "interactions/InteractionSettings"; import InteractionSettings from "interactions/InteractionSettings";
import { cloneDeep } from "lodash"; import { cloneDeep } from "lodash";
@ -40,9 +43,10 @@ import { getDefaultInteraction } from "pbHelpers/Interaction";
import { pond } from "protobuf-ts/pond"; import { pond } from "protobuf-ts/pond";
import { useGlobalState } from "providers"; import { useGlobalState } from "providers";
import { useTeamAPI } from "providers/pond/teamAPI"; import { useTeamAPI } from "providers/pond/teamAPI";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
// import { useRouteMatch } from "react-router"; // import { useRouteMatch } from "react-router";
import { isStaleStatusUpdate } from "utils/syncStatus";
import { or } from "utils/types"; import { or } from "utils/types";
const useStyles = makeStyles((theme: Theme) => { const useStyles = makeStyles((theme: Theme) => {
@ -142,6 +146,55 @@ export default function DeviceComponent() {
const [deviceComponentPrefs, setDeviceComponentPrefs] = useState< const [deviceComponentPrefs, setDeviceComponentPrefs] = useState<
pond.DeviceComponentPreferences pond.DeviceComponentPreferences
>(); >();
const { token } = useHTTP();
const componentPending = usePendingChanges(
component.status.synced,
!loadingInitial && component.key() !== ""
);
const liveContextKeys = useMemo(() => getContextKeys(), []);
const liveContextTypes = useMemo(() => getContextTypes(), []);
/** Matches the REST calls: omit ?as= when first context type is "team". */
const liveAs =
as && !(liveContextTypes.length > 0 && liveContextTypes[0] === "team")
? as
: undefined;
// --- Real-time component updates ---
// Streams component changes for this device, including status changes
// when the device accepts pending component updates.
useWebSocket<{ key: string; component: Component } | null>({
path: `/v1/live/devices/${deviceID}/components`,
parse: (e) => {
try {
const raw = JSON.parse(e.data);
const comp = Component.any(raw);
if (!comp?.settings) {
console.warn("[ws] received component without settings:", raw);
return null;
}
return { key: comp.key(), component: comp };
} catch (err) {
console.warn("[ws] failed to parse component:", err);
return null;
}
},
onMessage: (data) => {
if (!data || data.key !== componentID) return;
setComponent((prev) => {
if (prev.key() !== data.key) return prev;
if (isStaleStatusUpdate(prev.status, data.component.status)) {
return prev;
}
return data.component;
});
},
keys: liveContextKeys,
types: liveContextTypes,
as: liveAs,
token,
enabled: !!deviceID && componentID !== "",
});
const setDefaultState = () => { const setDefaultState = () => {
setDevice(Device.create()); setDevice(Device.create());
@ -389,9 +442,12 @@ export default function DeviceComponent() {
alignItems="center" alignItems="center"
wrap="nowrap" wrap="nowrap"
className={classes.overviewContainer}> className={classes.overviewContainer}>
{!component.status.synced && ( {(componentPending.pending || componentPending.accepted) && (
<Grid > <Grid >
<StatusChip key="status" status="pending" /> <PendingChangesChip
pending={componentPending.pending}
accepted={componentPending.accepted}
/>
</Grid> </Grid>
)} )}
<Grid > <Grid >

View file

@ -1,67 +0,0 @@
import { cyan } from "@mui/material/colors";
import AcceptedIcon from "@mui/icons-material/CloudDone";
import PendingIcon from "@mui/icons-material/CloudQueueTwoTone";
import RejectedIcon from "@mui/icons-material/SmsFailed";
export interface StatusHelper {
description: string;
icon: any;
}
const Unknown: StatusHelper = {
description: "Status unknown",
icon: null
};
const Pending: StatusHelper = {
description: "Pending changes",
icon: <PendingIcon style={{ color: cyan["600"] }} />
};
const Stale: StatusHelper = {
description: "Stale update",
icon: null
};
const Accepted: StatusHelper = {
description: "Settings synced",
icon: <AcceptedIcon style={{ color: "var(--status-ok)" }} />
};
const Rejected: StatusHelper = {
description: "Update failed",
icon: <RejectedIcon style={{ color: "var(--status-warning)" }} />
};
const Received: StatusHelper = {
description: "Settings synced",
icon: <AcceptedIcon style={{ color: "var(--status-ok)" }} />
};
const STATUS_MAP = new Map<string, StatusHelper>([
["pending", Pending],
["stale", Stale],
["accepted", Accepted],
["rejected", Rejected],
["received", Received]
]);
export function getStatusHelper(status: string): StatusHelper {
const statuses = Array.from(STATUS_MAP.keys());
for (var i = 0; i < statuses.length; i++) {
let key = statuses[i];
if (status === key) {
return STATUS_MAP.get(key) as StatusHelper;
}
}
return Unknown;
}
export function getStatusDescription(status: string): string {
return getStatusHelper(status).description;
}
export function getStatusIcon(status: string): any {
return getStatusHelper(status).icon;
}

View file

@ -62,6 +62,14 @@ export interface IComponentAPIContext {
keys?: string[], keys?: string[],
types?: string[] types?: string[]
) => Promise<AxiosResponse<pond.ListComponentHistoryResponse>>; ) => Promise<AxiosResponse<pond.ListComponentHistoryResponse>>;
listHistoryBetween: (
deviceId: string | number,
componentKey: string,
start: string,
end: string,
keys?: string[],
types?: string[]
) => Promise<AxiosResponse<pond.ListComponentHistoryBetweenResponse>>;
// Old measuremnt structure functions, no longer used in codebase // Old measuremnt structure functions, no longer used in codebase
// listMeasurements: ( // listMeasurements: (
// device: number, // device: number,
@ -391,6 +399,36 @@ export default function ComponentProvider(props: PropsWithChildren<Props>) {
}) })
}; };
const listHistoryBetween = (
deviceId: string | number,
componentKey: string,
start: string,
end: string,
keys?: string[],
types?: string[]
) => {
let url = pondURL(
"/devices/" +
deviceId +
"/components/" +
componentKey +
"/historyBetween?start=" +
start +
"&end=" +
end +
(keys ? "&keys=" + keys : "&keys=" + [deviceId.toString()]) +
(types ? "&types=" + types : "&types=" + ["device"])
)
return new Promise<AxiosResponse<pond.ListComponentHistoryBetweenResponse>>((resolve,reject) => {
get<pond.ListComponentHistoryBetweenResponse>(url).then(resp => {
resp.data = pond.ListComponentHistoryBetweenResponse.fromObject(resp.data)
return resolve(resp)
}).catch(err => {
return reject(err)
})
})
};
// const listMeasurements = ( // const listMeasurements = (
// device: number, // device: number,
// component: string, // component: string,
@ -596,6 +634,7 @@ export default function ComponentProvider(props: PropsWithChildren<Props>) {
listForObject: listComponentsForObject, listForObject: listComponentsForObject,
listComponentCardData: listComponentCardData, listComponentCardData: listComponentCardData,
listHistory, listHistory,
listHistoryBetween,
//listMeasurements: listMeasurements, //listMeasurements: listMeasurements,
//sampleMeasurements: sampleMeasurements, //sampleMeasurements: sampleMeasurements,
sampleUnitMeasurements, sampleUnitMeasurements,

View file

@ -2,5 +2,6 @@ export * from "./download";
export * from "./environment"; export * from "./environment";
export * from "./numbers"; export * from "./numbers";
export * from "./strings"; export * from "./strings";
export * from "./syncStatus";
export * from "./types"; export * from "./types";
export * from "./units"; export * from "./units";

41
src/utils/syncStatus.ts Normal file
View file

@ -0,0 +1,41 @@
export function getTimestampMillis(timestamp?: string): number | undefined {
if (!timestamp) return undefined;
const parsed = Date.parse(timestamp);
return Number.isNaN(parsed) ? undefined : parsed;
}
/** The sync-tracking fields shared by ComponentStatus and InteractionStatus. */
export interface SyncStatusLike {
synced: boolean;
lastUpdate?: string;
lastSynced?: string;
}
/**
* Decides whether an incoming websocket status message is stale and should
* not replace the status we already hold. A message is stale when it was
* generated before our latest local update, including a synced echo that
* predates a pending change we just submitted.
*/
export function isStaleStatusUpdate(existing: SyncStatusLike, incoming: SyncStatusLike): boolean {
const existingLastUpdate = getTimestampMillis(existing.lastUpdate);
const incomingLastUpdate = getTimestampMillis(incoming.lastUpdate);
const incomingLastSynced = getTimestampMillis(incoming.lastSynced);
if (
existingLastUpdate !== undefined &&
incomingLastUpdate !== undefined &&
existingLastUpdate > incomingLastUpdate
) {
return true;
}
if (
!existing.synced &&
incoming.synced &&
incomingLastUpdate !== undefined &&
existingLastUpdate === incomingLastUpdate &&
(incomingLastSynced === undefined || incomingLastSynced < incomingLastUpdate)
) {
return true;
}
return false;
}