From 3ce00facd1856fb1d7012f28098ec2f56a117098 Mon Sep 17 00:00:00 2001 From: Carter Date: Wed, 8 Jan 2025 14:45:45 -0600 Subject: [PATCH] tag stuff --- package-lock.json | 100 +++++++++++- package.json | 5 +- src/app/FirmwareLoader.tsx | 48 ++++++ src/app/UserWrapper.tsx | 17 ++- src/common/ColourPicker.tsx | 42 +++++ src/common/StatusChip.tsx | 20 +++ src/common/Tag.tsx | 62 ++++++++ src/common/TagSettings.tsx | 233 ++++++++++++++++++++++++++++ src/device/DeviceOverview.tsx | 238 +++++++++++++++++++++++++++++ src/device/DeviceTags.tsx | 179 ++++++++++++++++++++++ src/device/VersionChip.tsx | 33 ++++ src/models/Component.ts | 107 +++++++++++++ src/models/Firmware.ts | 42 +++++ src/models/index.ts | 4 +- src/pages/Device.tsx | 19 +++ src/pbHelpers/Connectivity.tsx | 100 ++++++++++++ src/pbHelpers/FirmwareVersion.tsx | 44 ++++++ src/pbHelpers/Status.tsx | 68 +++++++++ src/providers/StateContainer.tsx | 3 +- src/providers/index.ts | 4 +- src/providers/pond/firmwareAPI.tsx | 205 +++++++++++++++++++++++++ src/providers/pond/pond.tsx | 10 +- src/providers/pond/tagAPI.tsx | 56 +++++++ 23 files changed, 1621 insertions(+), 18 deletions(-) create mode 100644 src/app/FirmwareLoader.tsx create mode 100644 src/common/ColourPicker.tsx create mode 100644 src/common/StatusChip.tsx create mode 100644 src/common/Tag.tsx create mode 100644 src/common/TagSettings.tsx create mode 100644 src/device/DeviceOverview.tsx create mode 100644 src/device/DeviceTags.tsx create mode 100644 src/device/VersionChip.tsx create mode 100644 src/models/Component.ts create mode 100644 src/models/Firmware.ts create mode 100644 src/pbHelpers/Connectivity.tsx create mode 100644 src/pbHelpers/FirmwareVersion.tsx create mode 100644 src/pbHelpers/Status.tsx create mode 100644 src/providers/pond/firmwareAPI.tsx create mode 100644 src/providers/pond/tagAPI.tsx diff --git a/package-lock.json b/package-lock.json index d97d0c0..4b3c3c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -25,17 +25,20 @@ "notistack": "^3.0.1", "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#dev", "react": "^18.3.1", + "react-color": "^2.19.3", "react-dom": "^18.3.1", "react-image": "^4.1.0", "react-infinite-scroller": "^1.2.6", "react-phone-input-2": "^2.15.1", - "react-router-dom": "^6.28.0" + "react-router-dom": "^6.28.0", + "react-virtualized-auto-sizer": "^1.0.25" }, "devDependencies": { "@babel/types": "^7.25.8", "@eslint/js": "^9.11.1", "@types/lodash": "^4.17.13", "@types/react": "^18.3.10", + "@types/react-color": "^3.0.13", "@types/react-dom": "^18.3.0", "@types/react-infinite-scroller": "^1.2.5", "@vitejs/plugin-react": "^4.3.2", @@ -1211,6 +1214,15 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@icons/material": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@icons/material/-/material-0.2.4.tgz", + "integrity": "sha512-QPcGmICAPbGLGb6F/yNf/KzKqvFx8z5qx3D1yFqVAjoFmXK35EgyW+cJ57Te3CNsmzblwtzakLGFqHPqrfb4Tw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", @@ -2152,6 +2164,19 @@ "csstype": "^3.0.2" } }, + "node_modules/@types/react-color": { + "version": "3.0.13", + "resolved": "https://registry.npmjs.org/@types/react-color/-/react-color-3.0.13.tgz", + "integrity": "sha512-2c/9FZ4ixC5T3JzN0LP5Cke2Mf0MKOP2Eh0NPDPWmuVH3NjPyhEjqNMQpN1Phr5m74egAy+p2lYNAFrX1z9Yrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/reactcss": "*" + }, + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-dom": { "version": "18.3.1", "integrity": "sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ==", @@ -2177,6 +2202,16 @@ "@types/react": "*" } }, + "node_modules/@types/reactcss": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/@types/reactcss/-/reactcss-1.2.13.tgz", + "integrity": "sha512-gi3S+aUi6kpkF5vdhUsnkwbiSEIU/BEJyD7kBy2SudWBUuKmJk8AQKE0OVcQQeEy40Azh0lV6uynxlikYIJuwg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.11.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.11.0.tgz", @@ -3774,6 +3809,12 @@ "version": "4.17.21", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash-es": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", + "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", + "license": "MIT" + }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", @@ -3840,6 +3881,12 @@ "@jridgewell/sourcemap-codec": "^1.5.0" } }, + "node_modules/material-colors": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/material-colors/-/material-colors-1.2.6.tgz", + "integrity": "sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==", + "license": "ISC" + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3939,9 +3986,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "dev": true, "funding": [ { @@ -3949,6 +3996,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "bin": { "nanoid": "bin/nanoid.cjs" }, @@ -4197,6 +4245,7 @@ "node_modules/protobuf-ts": { "version": "1.0.0", "resolved": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#e3d1689e1bf3842b0601cf166be9aff78ef093ff", + "integrity": "sha512-hlkiHxih/Z+kBGMoyWI2Y/vn6afNqRElGuZ/LLGPaLZe2BLXRLKAQOHzR3a7SLfz6p2TkZISNdpmeveK7xgKSg==", "dependencies": { "protobufjs": "^6.8.8" } @@ -4270,6 +4319,24 @@ "node": ">=0.10.0" } }, + "node_modules/react-color": { + "version": "2.19.3", + "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.19.3.tgz", + "integrity": "sha512-LEeGE/ZzNLIsFWa1TMe8y5VYqr7bibneWmvJwm1pCn/eNmrabWDh659JSPn9BuaMpEfU83WTOJfnCcjDZwNQTA==", + "license": "MIT", + "dependencies": { + "@icons/material": "^0.2.4", + "lodash": "^4.17.15", + "lodash-es": "^4.17.15", + "material-colors": "^1.2.1", + "prop-types": "^15.5.10", + "reactcss": "^1.2.0", + "tinycolor2": "^1.4.1" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-dom": { "version": "18.3.1", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", @@ -4374,6 +4441,25 @@ "react-dom": ">=16.6.0" } }, + "node_modules/react-virtualized-auto-sizer": { + "version": "1.0.25", + "resolved": "https://registry.npmjs.org/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.25.tgz", + "integrity": "sha512-YHsksEGDfsHbHuaBVDYwJmcktblcHGafz4ZVuYPQYuSHMUGjpwmUCrAOcvMSGMwwk1eFWj1M/1GwYpNPuyhaBg==", + "license": "MIT", + "peerDependencies": { + "react": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^15.3.0 || ^16.0.0-alpha || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/reactcss": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/reactcss/-/reactcss-1.2.3.tgz", + "integrity": "sha512-KiwVUcFu1RErkI97ywr8nvx8dNOpT03rbnma0SSalTYjkrPYaEajR4a/MRt6DZ46K6arDRbWMNHF+xH7G7n/8A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.0.1" + } + }, "node_modules/regenerator-runtime": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", @@ -4602,6 +4688,12 @@ "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", "dev": true }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.1.tgz", diff --git a/package.json b/package.json index 292a34e..ce70d96 100644 --- a/package.json +++ b/package.json @@ -34,17 +34,20 @@ "notistack": "^3.0.1", "protobuf-ts": "git+https://gitlab+deploy-token-50627:hv8mB4WkyvtjBpJKU1rN@gitlab.com/brandx/protobuf-ts.git#dev", "react": "^18.3.1", + "react-color": "^2.19.3", "react-dom": "^18.3.1", "react-image": "^4.1.0", "react-infinite-scroller": "^1.2.6", "react-phone-input-2": "^2.15.1", - "react-router-dom": "^6.28.0" + "react-router-dom": "^6.28.0", + "react-virtualized-auto-sizer": "^1.0.25" }, "devDependencies": { "@babel/types": "^7.25.8", "@eslint/js": "^9.11.1", "@types/lodash": "^4.17.13", "@types/react": "^18.3.10", + "@types/react-color": "^3.0.13", "@types/react-dom": "^18.3.0", "@types/react-infinite-scroller": "^1.2.5", "@vitejs/plugin-react": "^4.3.2", diff --git a/src/app/FirmwareLoader.tsx b/src/app/FirmwareLoader.tsx new file mode 100644 index 0000000..c1ce4ac --- /dev/null +++ b/src/app/FirmwareLoader.tsx @@ -0,0 +1,48 @@ +import { useEffect, useState } from 'react' +import './App.css' +import LoadingScreen from './LoadingScreen' +import { useFirmwareAPI, useGlobalState } from 'providers' +import { Firmware } from 'models' + +interface Props { + children: any; +} + +export default function FirmwareLoader(props: Props) { + const { children } = props; + const [loading, setLoading] = useState(false) + const firmwareAPI = useFirmwareAPI(); + const [global, dispatch] = useGlobalState(); + + const loadFirmware = () => { + setLoading(true) + firmwareAPI.getAllLatestFirmware().then(resp => { + let versions: Map = new Map(); + if (resp && resp.data && resp.data.firmware) { + resp.data.firmware.forEach((raw: any) => { + let firmware = Firmware.any(raw); + let key = firmware.settings.platform + ":" + firmware.settings.channel; + versions.set(key, firmware); + }); + } + dispatch({ key: "firmware", value: versions }); + }).finally(() => { + setLoading(false) + }) + } + + useEffect(() => { + if (loading) return; + loadFirmware() + }, [loading]) + + if (loading || !global) return ( + + ) + + return ( + <> + {children} + + ) +} \ No newline at end of file diff --git a/src/app/UserWrapper.tsx b/src/app/UserWrapper.tsx index 4703598..0d4055b 100644 --- a/src/app/UserWrapper.tsx +++ b/src/app/UserWrapper.tsx @@ -10,6 +10,7 @@ import { User } from '../models/user' import { Team } from '../models/team' import { makeStyles } from '@mui/styles' import { Theme } from '@mui/material' +import FirmwareLoader from './FirmwareLoader' const reducer = (state: GlobalState, action: GlobalStateAction): GlobalState => { return { @@ -45,7 +46,8 @@ const globalDefault = { as: "", showErrors: false, userTeamPermissions: [], - backgroundTasksComplete: false + backgroundTasksComplete: false, + firmware: new Map() } interface Props { @@ -68,15 +70,14 @@ export default function UserWrapper(props: Props) { if (hasFetched.current) return; setLoading(true) userAPI.getUserWithTeam(user_id).then(resp => { - // console.log(resp.data.team) - // console.log(resp.data.user) setGlobal({ user: resp.data.user ? User.create(resp.data.user) : User.create(), team: resp.data.team ? Team.create(resp.data.team) : Team.create(), as: "", showErrors: false, userTeamPermissions: [], - backgroundTasksComplete: false + backgroundTasksComplete: false, + firmware: new Map() }) }).catch(() => { setGlobal(globalDefault) @@ -98,9 +99,11 @@ export default function UserWrapper(props: Props) { return ( -
- -
+ +
+ +
+
) } \ No newline at end of file diff --git a/src/common/ColourPicker.tsx b/src/common/ColourPicker.tsx new file mode 100644 index 0000000..35ce9e9 --- /dev/null +++ b/src/common/ColourPicker.tsx @@ -0,0 +1,42 @@ +import { TwitterPicker } from "react-color"; +import AutoSizer from "react-virtualized-auto-sizer"; + +interface Props { + colour?: string; + onChange: (colour: string) => void; +} + +export default function ColourPicker(props: Props) { + const { colour, onChange } = props; + + return ( + + {({ width }: any) => ( + onChange(colour.hex)} + width={width} + triangle="hide" + colors={[ + "#f44336", + "#e91e63", + "#9c27b0", + "#673ab7", + "#3f51b5", + "#2196f3", + "#03a9f4", + "#00bcd4", + "#009688", + "#4caf50", + "#8bc34a", + "#cddc39", + "#ffeb3b", + "#ffc107", + "#ff9800", + "#ff5722" + ]} + /> + )} + + ); +} diff --git a/src/common/StatusChip.tsx b/src/common/StatusChip.tsx new file mode 100644 index 0000000..c1b16b3 --- /dev/null +++ b/src/common/StatusChip.tsx @@ -0,0 +1,20 @@ +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 ? ( + + + + ) : null; +} diff --git a/src/common/Tag.tsx b/src/common/Tag.tsx new file mode 100644 index 0000000..812cf10 --- /dev/null +++ b/src/common/Tag.tsx @@ -0,0 +1,62 @@ +import { Chip, Theme } from "@mui/material"; +import { createStyles, makeStyles } from "@mui/styles"; +import DeleteIcon from "@mui/icons-material/Cancel"; +import { Tag as TagModel } from "models"; + +const useStyles = makeStyles((_theme: Theme) => + createStyles({ + deleteIconLightBG: { + color: "rgba(0, 0, 0, 0.26)", + "&:hover": { + color: "rgba(0, 0, 0, 0.4)" + } + }, + deleteIconDarkBG: { + color: "rgba(255, 255, 255, 0.26)", + "&:hover": { + color: "rgba(255, 255, 255, 0.4)" + } + } + }) +); + +interface Props { + tag?: TagModel; + onClick?: () => void; + onDelete?: () => void; +} + +export function Tag(props: Props) { + const classes = useStyles(); + const { tag, onClick, onDelete } = props; + const colour = tag ? tag.settings.colour : undefined; + + const getBrightnessLevel = () => { + if (!colour) return null; + const hex = colour.replace("#", ""); + const c_r = parseInt(hex.substr(0, 2), 16); + const c_g = parseInt(hex.substr(2, 2), 16); + const c_b = parseInt(hex.substr(4, 2), 16); + const brightness = (c_r * 299 + c_g * 587 + c_b * 114) / 1000; + return brightness; + }; + + const brightness = getBrightnessLevel(); + const isLightBG = brightness !== null && brightness > 169; + + + return ( + + } + /> + ); +} diff --git a/src/common/TagSettings.tsx b/src/common/TagSettings.tsx new file mode 100644 index 0000000..48ccf58 --- /dev/null +++ b/src/common/TagSettings.tsx @@ -0,0 +1,233 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Grid2 as Grid, + TextField, + Theme +} from "@mui/material"; +// import { red } from "@mui/icons-materia"; +// import { createStyles, makeStyles, Theme, withStyles } from "@material-ui/core/styles"; +import { Delete as DeleteIcon } from "@mui/icons-material"; +import ColourPicker from "common/ColourPicker"; +import { Tag as TagModel } from "models"; +import { useGlobalState, useTagAPI, useSnackbar } from "providers"; +import { useState } from "react"; +import { Tag as TagUI } from "./Tag"; +import { red } from "@mui/material/colors"; +import { makeStyles, withStyles } from "@mui/styles"; + +const useStyles = makeStyles((theme: Theme) => { + return ({ + gutter: { + marginBottom: theme.spacing(1), + [theme.breakpoints.up("sm")]: { + marginBottom: theme.spacing(2) + } + } + }) +}); + +interface ConfirmDeleteTagProps { + open: boolean; + onClose: (deleted?: boolean) => void; + tag: TagModel; +} + +function ConfirmDeleteTag(props: ConfirmDeleteTagProps) { + const { tag, open, onClose } = props; + const { error, success } = useSnackbar(); + // const [{ tags }, dispatch] = useGlobalState(); + const tagAPI = useTagAPI(); + const tagName = tag.name(); + + const onSubmit = () => { + if (tag && tag.settings.key) { + tagAPI + .removeTag(tag.settings.key) + .then(() => { + // dispatch({ key: "tags", value: tags.filter(t => t.settings.key !== tag.settings.key) }); + success("Tag was successfully deleted"); + onClose(true); + }) + .catch((err: Error) => { + error(err && err.message ? err.message : "Error occured while removing the tag"); + onClose(); + }); + } + }; + + return ( + onClose()} + aria-labelledby="confirm-delete-tag-title" + aria-describedby="confirm-delete-tag-description"> + Delete the tag {tagName}? + + + By clicking 'Submit', the tag {tagName} will be deleted; anything associated with it will + longer be able to access it. + + + + + + + + ); +} + +interface Props { + open: boolean; + onClose: () => void; + tag?: TagModel; + mode: "add" | "update"; +} + +const DeleteButton = withStyles((theme: Theme) => ({ + root: { + color: "#FFF", + backgroundColor: red[500], + "&:hover": { + backgroundColor: red[700] + } + } +}))(Button); + +export default function TagSettings(props: Props) { + const classes = useStyles(); + const { open, mode, onClose } = props; + const { error, success } = useSnackbar(); + const tagAPI = useTagAPI(); + const [tag, setTag] = useState(TagModel.clone(props.tag)); + const [confirmDeleteTagOpen, setConfirmDeleteTagOpen] = useState(false); + // const [{ tags }, dispatch] = useGlobalState(); + + const submit = () => { + switch (mode) { + case "add": + tagAPI + .addTag(tag.settings) + .then((response: any) => { + let newKey = response && response.data && response.data.key ? response.data.key : null; + if (newKey) { + tag.settings.key = newKey; + } + // dispatch({ key: "tags", value: [...tags, tag] }); + success("Successfully created a new tag"); + handleClose(true); + }) + .catch((err: Error) => { + error(err && err.message ? err.message : "Error occured while create a new tag"); + handleClose(); + }); + break; + default: + tagAPI + .updateTag(tag.settings.key, tag.settings) + .then((response: any) => { + // dispatch({ + // key: "tags", + // value: tags.map(t => { + // if (t.settings.key !== tag.settings.key) { + // return t; + // } + // return tag; + // }) + // }); + success("Successfully updated " + tag.name()); + handleClose(true); + }) + .catch((err: Error) => { + error(err && err.message ? err.message : "Error occured while updating the tag"); + handleClose(); + }); + break; + } + }; + + const onConfirmDeleteTag = (tagDeleted?: boolean) => { + if (tagDeleted) { + onClose(); + } else { + setConfirmDeleteTagOpen(false); + } + }; + + const handleClose = (refresh?: boolean) => { + if (mode === "add") { + setTag(new TagModel()); + } + onClose(); + }; + + return ( + handleClose()}> + + + + + { + let newTag = TagModel.clone(tag); + newTag.settings.name = event.target.value; + setTag(newTag); + }} + placeholder="Enter a label for your tag" + variant="outlined" + className={classes.gutter} + /> + + { + tag.settings.colour = colour; + let newTag = TagModel.clone(tag); + newTag.settings.colour = colour; + setTag(newTag); + }} + /> + + + + + {mode !== "add" && ( + setConfirmDeleteTagOpen(true)}> + + + )} + + + + + + + + + {mode !== "add" && ( + onConfirmDeleteTag(tagDeleted)} + tag={tag} + /> + )} + + ); +} diff --git a/src/device/DeviceOverview.tsx b/src/device/DeviceOverview.tsx new file mode 100644 index 0000000..f20fad0 --- /dev/null +++ b/src/device/DeviceOverview.tsx @@ -0,0 +1,238 @@ +import { Chip, Grid2 as Grid, Theme, Tooltip, Skeleton } from "@mui/material"; +import { DataUsage, SimCard, Launch } from "@mui/icons-material"; +import StatusChip from "common/StatusChip"; +import DeviceTags from "device/DeviceTags"; +import VersionChip from "device/VersionChip"; +import { useSnackbar, useWidth, usePrevious } from "hooks"; +import { Device, latestFirmwareVersion, Component } from "models"; +import moment from "moment"; +import { describeConnectivity } from "pbHelpers/Connectivity"; +import { describePower } from "pbHelpers/Power"; +import { pond, quack } from "protobuf-ts/pond"; +import { useGlobalState } from "providers"; +import React, { useEffect, useState } from "react"; +// import { useHistory, useRouteMatch } from "react-router"; +import { notNull, or } from "utils/types"; +// import { MatchParams } from "navigation/Routes"; +// import DeviceHologram from "./DeviceHologram"; +import { makeStyles } from "@mui/styles"; +import { useNavigate, useParams } from "react-router-dom"; + +const useStyles = makeStyles((_theme: Theme) => { + return ({ + chipContainer: { + position: "relative", + maxWidth: "100%", + overflowX: "auto" + } + }) +}); + +interface Usage { + status: string; + bytes: number; +} + +interface Props { + device: Device; + components: Component[]; + usage?: Usage; + loading?: boolean; + disableAddTag?: boolean; +} + +export default function DeviceOverview(props: Props) { + const [{ user, firmware }] = useGlobalState(); + const { device, components, usage, loading, disableAddTag } = props; + const prevComponents = usePrevious(components); + const { info } = useSnackbar(); + const classes = useStyles(); + const width = useWidth(); + const now = moment(); + const navigate = useNavigate(); + // const match = useRouteMatch(); + const [powerComponent, setPowerComponent] = useState(); + const [modemComponent, setModemComponent] = useState(); + // const [hologramDialog, setHologramDialog] = useState(false); + + useEffect(() => { + if (components !== prevComponents) { + let updatedPower = null; + let updatedModem = null; + components.forEach(c => { + switch (c.settings.type) { + case quack.ComponentType.COMPONENT_TYPE_POWER: + updatedPower = c; + break; + case quack.ComponentType.COMPONENT_TYPE_MODEM: + updatedModem = c; + break; + default: + break; + } + }); + setPowerComponent(updatedPower); + setModemComponent(updatedModem); + } + }, [components, prevComponents]); + + const pathToDevice = () => { + const groupID = parseInt(useParams().groupID ?? "", 10); + // const groupID: number = parseInt(match.params.groupID, 10); + const groupPath: string = groupID > 0 ? "/groups/" + groupID.toString() : ""; + const devicePath: string = "/devices/" + device.settings.deviceId.toString(); + return groupPath + devicePath; + }; + + const copySim = (sim: string) => { + navigator.clipboard.writeText(sim); + info("SIM copied to clipboard"); + }; + + const chips = () => { + const currentVersion = device.status.firmwareVersion; + const hasVersion: boolean = currentVersion && currentVersion !== "" ? true : false; + const connectivity = describeConnectivity( + device.settings.platform, + device.status.lastActive, + device.settings.pondCheckPeriodS, + now + ); + const latestFirmware = latestFirmwareVersion( + firmware, + device.settings.platform, + device.settings.upgradeChannel + ); + const power = describePower( + pond.DevicePower.create(device.status.power ? device.status.power : undefined) + ); + const iccid = device.status.sim; + let usageDesc = ""; + if (usage && isNaN(usage.bytes)) { + usageDesc = "0 B"; + } else if (usage && usage.bytes < 1000) { + usageDesc = usage.bytes + " B"; + } else if (usage && usage.bytes < 1000000) { + usageDesc = (usage.bytes / 1000.0).toFixed(1) + " KB"; + } else if (usage && usage.bytes < 1000000000) { + usageDesc = (usage.bytes / 1000000.0).toFixed(1) + " MB"; + } else if (usage) { + usageDesc = (usage.bytes / 1000000000.0).toFixed(1) + " GB"; + } + + let isPaused = false; + if ( + usage && + or(usage.status, "") + .toLowerCase() + .includes("pause") + ) { + isPaused = true; + } + let isCellular = true; + let canHologram = user.hasFeature("billing"); + // device.settings.platform === pond.DevicePlatform.DEVICE_PLATFORM_ELECTRON || + // device.settings.platform === pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR; + return ( + + {device.settings.platform > 0 && ( + + + { + if (modemComponent && modemComponent.key()) { + navigate(pathToDevice() + "/components/" + modemComponent.key()); + } + }} + label={connectivity.description} + icon={connectivity.icon} + onDelete={() => { + if (modemComponent && modemComponent.key()) { + navigate(pathToDevice() + "/components/" + modemComponent.key()); + } + }} + deleteIcon={modemComponent ? : } + /> + + + )} + {notNull(device.status.power) && ( + + + { + if (powerComponent && powerComponent.key()) { + navigate(pathToDevice() + "/components/" + powerComponent.key()); + } + }} + label={power.description} + icon={power.icon} + onDelete={() => { + if (powerComponent && powerComponent.key()) { + navigate(pathToDevice() + "/components/" + powerComponent.key()); + } + }} + deleteIcon={powerComponent ? : } + /> + + + )} + + {hasVersion && ( + + + + )} + {isCellular && iccid && ( + + + copySim(iccid)} + label={"SIM " + iccid.substring(iccid.length - 5)} + icon={} + /> + + + )} + {isCellular && usage && usage.bytes > 0 && ( + + + } + clickable={canHologram} + onClick={() => { + // if (canHologram) setHologramDialog(true); + }} + /> + + + )} + {!device.status.synced && ( + + + + )} + {user.allowedTo("provision") && } + + ); + }; + + return ( + + {loading ? : chips()} + {/* */} + + ); +} diff --git a/src/device/DeviceTags.tsx b/src/device/DeviceTags.tsx new file mode 100644 index 0000000..c7e4262 --- /dev/null +++ b/src/device/DeviceTags.tsx @@ -0,0 +1,179 @@ +import { + Dialog, + DialogContent, + DialogTitle, + Grid2 as Grid, + IconButton, + List, + ListItem, + ListItemIcon, + ListItemText, + Theme, + Typography +} from "@mui/material"; +import { makeStyles } from "@mui/styles"; +import { AddCircleOutline as AddIconCircle } from "@mui/icons-material"; +import SearchBar from "common/SearchBar"; +import { Tag as TagUI } from "common/Tag"; +import TagSettings from "common/TagSettings"; +import { Device, Tag } from "models"; +import { filterByTag } from "pbHelpers/Tag"; +import { useDeviceAPI, useGlobalState, useSnackbar } from "providers"; +import React, { useEffect, useRef, useState } from "react"; + +const useStyles = makeStyles((theme: Theme) => { + return ({ + addIcon: { + color: "var(--status-ok)" + } + }) +}); + +interface AddDeviceTagProps { + device: Device; + deviceTags: string[]; + addTagToDevice: (tag: Tag) => void; +} + +function AddDeviceTag(props: AddDeviceTagProps) { + const classes = useStyles(); + const { device, deviceTags, addTagToDevice } = props; + const [open, setOpen] = useState(false); + const [createNewOpen, setCreateNewOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + // const [{ tags }] = useGlobalState(); + const [tags, setTags] = useState([]) + + const tagItems = tags + .filter(tag => !deviceTags.some(key => key === tag.settings.key)) + .filter(tag => filterByTag(searchValue, tag)) + .sort((a, b) => (a.name().toLowerCase() > b.name().toLowerCase() ? 1 : -1)) + .map((tag, index, array) => ( + + addTagToDevice(tag)} /> + + )); + return ( + + setOpen(true)} size="small"> + + + setOpen(false)} scroll="paper"> + + Select tags to add + + {device.name()} + + + + setSearchValue(newSearchValue)} + /> + + {tagItems.length > 0 ? ( + {tagItems} + ) : ( + + + + )} + setCreateNewOpen(true)}> + + + + Create tag + + + + + setCreateNewOpen(false)} /> + + ); +} + +interface DeviceTagProps { + tag: Tag; + removeTagFromDevice: (tag: Tag) => void; +} + +function DeviceTag(props: DeviceTagProps) { + const { tag, removeTagFromDevice } = props; + const [open, setOpen] = useState(false); + + return ( + + setOpen(true)} onDelete={() => removeTagFromDevice(tag)} /> + setOpen(false)} tag={tag} mode="update" /> + + ); +} + +interface DeviceTagsProps { + device: Device; + disableAdd?: boolean; +} + +export default function DeviceTags(props: DeviceTagsProps) { + const { device, disableAdd } = props; + // const [{ tags }] = useGlobalState(); + const [tags, setTags] = useState([]) + const deviceAPI = useDeviceAPI(); + const { error } = useSnackbar(); + const [deviceTags, setDeviceTags] = useState(device.status.tagKeys); + const previousDeviceRef = useRef(device); + + useEffect(() => { + if (previousDeviceRef.current !== device) { + setDeviceTags(device.status.tagKeys); + previousDeviceRef.current = device; + } + }, [device]); + + const addTag = (tag: Tag) => { + if (!deviceTags.some(dt => dt === tag.key())) { + deviceAPI + .tag(device.id(), tag.key()) + .then(() => { + setDeviceTags([...deviceTags, tag.key()]); + }) + .catch(() => error("Failed to tag device as " + tag.name())); + } + }; + + const removeTagFromDevice = (tag: Tag) => { + if (deviceTags.some(dt => dt === tag.key())) { + deviceAPI + .untag(device.id(), tag.key()) + .then(() => { + setDeviceTags(deviceTags.filter(t => tag.key() !== t)); + }) + .catch(() => error("Failed to remove tag " + tag.name() + " from device")); + } + }; + + if (!device || !tags) { + return null; + } + + return ( + + {deviceTags.map(key => { + let tag = tags.find(t => t.settings.key === key); + if (!tag) { + return null; + } + return ( + + + + ); + })} + {disableAdd === undefined || disableAdd === false ? ( + + + + ) : null} + + ); +} diff --git a/src/device/VersionChip.tsx b/src/device/VersionChip.tsx new file mode 100644 index 0000000..0389441 --- /dev/null +++ b/src/device/VersionChip.tsx @@ -0,0 +1,33 @@ +import { Chip, Theme, Tooltip } from "@mui/material"; +// import { Theme } from "@material-ui/core/styles/createMuiTheme"; +// import withStyles, { WithStyles } from "@material-ui/core/styles/withStyles"; +import React from "react"; +import { getFirmwareVersionHelper } from "pbHelpers/FirmwareVersion"; +import { withStyles, WithStyles } from "@mui/styles"; + +const styles = (_theme: Theme) => ({}); + +interface Props extends WithStyles { + version: string; + available: string; +} + +interface State {} + +class VersionChip extends React.Component { + render() { + const { version, available } = this.props; + const firmwareVersionHelper = getFirmwareVersionHelper(version, available); + return ( + + + + ); + } +} + +export default withStyles(styles)(VersionChip); diff --git a/src/models/Component.ts b/src/models/Component.ts new file mode 100644 index 0000000..88b9a2a --- /dev/null +++ b/src/models/Component.ts @@ -0,0 +1,107 @@ +import { pond } from "protobuf-ts/pond"; +import { quack } from "protobuf-ts/pond"; +import { or } from "utils/types"; +import { cloneDeep } from "lodash"; +// import { getSubtypes } from "pbHelpers/ComponentType"; + +export class Component { + public settings: pond.ComponentSettings = pond.ComponentSettings.create(); + public status: pond.ComponentStatus = pond.ComponentStatus.create(); + public lastMeasurement: pond.UnitMeasurementsForComponent[] = []; + public lastGoodMeasurement: pond.UnitMeasurementsForComponent[] = []; + public permissions: pond.Permission[] = []; + + public static create(pb?: pond.Component): Component { + let my = new Component(); + if (pb) { + my.settings = pond.ComponentSettings.fromObject( + or(pb.settings, pond.ComponentSettings.create()) + ); + my.status = pond.ComponentStatus.fromObject(or(pb.status, pond.ComponentStatus.create())); + my.lastMeasurement = pb.lastMeasurement ?? pond.UnitMeasurementsForComponent.create(); + //my.lastGoodMeasurement = pb.status?.lastGoodMeasurement ?? pond.UnitMeasurementsForComponent.create(); + my.permissions = pb.permissions ?? []; + } + return my; + } + + public static clone(other?: Component): Component { + if (other) { + return Component.create( + pond.Component.fromObject(cloneDeep({ settings: other.settings, status: other.status })) + ); + } + return Component.create(); + } + + public static any(data: any): Component { + let my = Component.create(pond.Component.fromObject(cloneDeep(data))); + if (data && data.status && data.status.lastMeasurement) { + my.status.lastMeasurement = data.status.lastMeasurement; + } + if (data && data.status && data.status.lastGoodMeasurement) { + my.status.lastGoodMeasurement = []; + data.status.lastGoodMeasurement.forEach((m: any) => { + my.status.lastGoodMeasurement.push(m); + }); + } + return my; + } + + public update(other: Component) { + this.settings = other.settings; + this.status = other.status; + } + + public name(): string { + return this.settings.name !== "" ? this.settings.name : "Component " + this.key(); + } + + public key(): string { + return this.settings.key; + } + + public location(): quack.ComponentID { + return quack.ComponentID.fromObject({ + type: this.settings.type, + addressType: this.settings.addressType, + address: this.settings.address + }); + } + + public locationString(): string { + return ( + or(this.settings.type, 0).toString() + + "-" + + or(this.settings.addressType, 0).toString() + + "-" + + or(this.settings.address, 0).toString() + ); + } + + public type(): quack.ComponentType { + return this.settings.type; + } + + public subType(): number { + return this.settings.subtype; + } + + // public subTypeName(): string { + // let subtypes = getSubtypes(this.settings.type); + // let subName = "Default"; + // subtypes.forEach(sub => { + // if (this.settings.subtype === sub.key) { + // subName = sub.friendlyName; + // } + // }); + // return subName; + // } + + public numNodes(): number { + if (this.lastMeasurement[0] && this.lastMeasurement[0].values[0]) { + return this.lastMeasurement[0].values[0].values.length; + } + return 0; + } +} diff --git a/src/models/Firmware.ts b/src/models/Firmware.ts new file mode 100644 index 0000000..99e09dd --- /dev/null +++ b/src/models/Firmware.ts @@ -0,0 +1,42 @@ +import { pond } from "protobuf-ts/pond"; +import { or } from "utils/types"; +import { cloneDeep } from "lodash"; + +export function latestFirmwareVersion( + versions: Map, + platform: pond.DevicePlatform, + channel: pond.UpgradeChannel +): string { + const firmware = versions.get(platform + ":" + channel); + if (firmware) { + return firmware.settings.version; + } + return ""; +} + +export class Firmware { + public settings: pond.FirmwareSettings = pond.FirmwareSettings.create(); + public status: pond.FirmwareStatus = pond.FirmwareStatus.create(); + + public static create(pb?: pond.Firmware): Firmware { + let my = new Firmware(); + if (pb) { + my.settings = pond.FirmwareSettings.fromObject(cloneDeep(or(pb.settings, {}))); + my.status = pond.FirmwareStatus.fromObject(cloneDeep(or(pb.status, {}))); + } + return my; + } + + public static clone(other?: Firmware): Firmware { + let my = new Firmware(); + if (other) { + my.settings = pond.FirmwareSettings.fromObject(cloneDeep(other.settings)); + my.status = pond.FirmwareStatus.fromObject(cloneDeep(other.status)); + } + return my; + } + + public static any(data: any): Firmware { + return Firmware.create(pond.Firmware.fromObject(cloneDeep(data))); + } +} diff --git a/src/models/index.ts b/src/models/index.ts index 3eb605b..c1b7501 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -1,9 +1,9 @@ export * from "./Backpack"; // export * from "./Bin"; // export * from "./BinYard"; -// export * from "./Component"; +export * from "./Component"; export * from "./Device"; -// export * from "./Firmware"; +export * from "./Firmware"; export * from "./Group"; // export * from "./Interaction"; export * from "./Scope"; diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index bb64301..a877505 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -11,6 +11,7 @@ import SmartBreadcrumb from "common/SmartBreadcrumb"; import DeviceActions from "common/DeviceActions"; import { pond } from "protobuf-ts/pond"; import { cloneDeep } from "lodash"; +import DeviceOverview from "device/DeviceOverview"; // const useStyles = makeStyles((theme: Theme) => { // // const isMobile = useMobile() @@ -31,6 +32,9 @@ export default function DevicePage() { const [permissions, setPermissions] = useState([]) const [preferences, setPreferences] = useState(pond.DevicePreferences.create()) + const [cellularUsage, setCellularUsage] = useState(0); + const [cellularStatus, setCellularStatus] = useState(""); + const loadDevice = () => { if (loading) return // if (state?.device && parseInt(state.device.settings.deviceId) === device.id()) return @@ -96,6 +100,14 @@ export default function DevicePage() { ) + const getUsage = () => { + let usage = undefined; + if (cellularStatus && cellularStatus !== "") { + usage = { status: cellularStatus, bytes: cellularUsage }; + } + return usage; + }; + return ( {/* @@ -117,6 +129,13 @@ export default function DevicePage() { /> + ); } \ No newline at end of file diff --git a/src/pbHelpers/Connectivity.tsx b/src/pbHelpers/Connectivity.tsx new file mode 100644 index 0000000..3a6795d --- /dev/null +++ b/src/pbHelpers/Connectivity.tsx @@ -0,0 +1,100 @@ +import { + SignalCellular4Bar, + SignalCellularOff, + SignalWifi4Bar, + SignalWifiOff +} from "@mui/icons-material"; +import moment from "moment"; +import { pond } from "protobuf-ts/pond"; +import { getTextSecondary } from "theme/text"; + +export interface ConnectivityDescriber { + icon: any; + description: string; + tooltip: string; +} + +export enum Activity { + unknown = 0, + active = 1, + away = 2, + missing = 3 +} + +function getActivity(lastActive: string, checkInPeriod: number, now: moment.Moment): Activity { + let secondsSinceLastActive = moment.duration(now.diff(moment(lastActive))).asSeconds(); + if (secondsSinceLastActive > checkInPeriod * 3) { + return Activity.missing; + } else if (secondsSinceLastActive > checkInPeriod) { + return Activity.away; + } else if (isNaN(secondsSinceLastActive)) { + return Activity.unknown; + } + return Activity.active; +} + +function getColour(activity: Activity): string { + switch (activity) { + case Activity.active: + return "var(--status-ok)"; + case Activity.away: + return "var(--status-risk)"; + case Activity.missing: + return getTextSecondary(); + default: + return "var(--status-unknown)"; + } +} + +function getIcon(platform: pond.DevicePlatform, activity: Activity): JSX.Element { + let colour = getColour(activity); + if ( + platform === pond.DevicePlatform.DEVICE_PLATFORM_ELECTRON || + platform === pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR + ) { + if (activity === Activity.missing) { + return ; + } + return ; + } + if (activity === Activity.missing) { + return ; + } + return ; +} + +function getTooltip(platform: pond.DevicePlatform): string { + switch (platform) { + case pond.DevicePlatform.DEVICE_PLATFORM_ELECTRON || + pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR || + pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR_BLACK || + pond.DevicePlatform.DEVICE_PLATFORM_V2_CELLULAR_GREEN: + return "Communicates via Cellular"; + case pond.DevicePlatform.DEVICE_PLATFORM_PHOTON || + pond.DevicePlatform.DEVICE_PLATFORM_V2_WIFI_S3: + return "Communicates via Wi-Fi"; + default: + return ""; + } +} + +function getDescription(lastActive: string): string { + if (lastActive === "") { + return "Never active"; + } + return "Active " + moment(lastActive).fromNow(); +} + +export function describeConnectivity( + platform: pond.DevicePlatform, + lastActive: string, + checkInPeriod: number, + now: moment.Moment +): ConnectivityDescriber { + let activity = getActivity(lastActive, checkInPeriod, now); + return { + icon: getIcon(platform, activity), + description: getDescription(lastActive), + tooltip: getTooltip(platform) + }; +} diff --git a/src/pbHelpers/FirmwareVersion.tsx b/src/pbHelpers/FirmwareVersion.tsx new file mode 100644 index 0000000..d7dcc8c --- /dev/null +++ b/src/pbHelpers/FirmwareVersion.tsx @@ -0,0 +1,44 @@ +import FirmwareIcon from "@mui/icons-material/Security"; + +export interface FirmwareVersionHelper { + description: string; + icon: any; + tooltip: string; + colour: string; +} + +const oldFirmwareColour = "var(--status-warning)"; +const currentFirmwareColour = "var(--status-ok)"; + +export function getFirmwareVersionHelper( + version: string, + available: string +): FirmwareVersionHelper { + const firmwareIcon = ; + const oldFirmwareIcon = ; + const currentFirmwareIcon = ; + let helper = {} as FirmwareVersionHelper; + if (!version || version === "") { + helper.description = "Unknown version"; + helper.icon = oldFirmwareIcon; + helper.tooltip = "We don't know what version your device is, it may behave unexpectedly"; + } else if (available !== "" && version !== available) { + helper.description = version; + helper.icon = oldFirmwareIcon; + helper.tooltip = + "A firmware upgrade is available, some features may be disabled for out-of-date devices"; + } else if (available === "") { + helper.description = version; + helper.icon = firmwareIcon; + helper.tooltip = "Running version " + version; + } else { + helper.description = version; + helper.icon = currentFirmwareIcon; + helper.tooltip = "Your device is running the latest firmware"; + } + return helper; +} + +export function getFirmwareVersionColour(outdated: boolean): string { + return outdated ? oldFirmwareColour : currentFirmwareColour; +} diff --git a/src/pbHelpers/Status.tsx b/src/pbHelpers/Status.tsx new file mode 100644 index 0000000..0e6cd3a --- /dev/null +++ b/src/pbHelpers/Status.tsx @@ -0,0 +1,68 @@ +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"; +import React from "react"; + +export interface StatusHelper { + description: string; + icon: any; +} + +const Unknown: StatusHelper = { + description: "Status unknown", + icon: null +}; + +const Pending: StatusHelper = { + description: "Pending changes", + icon: +}; + +const Stale: StatusHelper = { + description: "Stale update", + icon: null +}; + +const Accepted: StatusHelper = { + description: "Settings synced", + icon: +}; + +const Rejected: StatusHelper = { + description: "Update failed", + icon: +}; + +const Received: StatusHelper = { + description: "Settings synced", + icon: +}; + +const STATUS_MAP = new Map([ + ["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; +} diff --git a/src/providers/StateContainer.tsx b/src/providers/StateContainer.tsx index 8f5cb13..b405677 100644 --- a/src/providers/StateContainer.tsx +++ b/src/providers/StateContainer.tsx @@ -3,13 +3,14 @@ import { createContext, useContext, useReducer, Dispatch } from "react"; import { pond } from "protobuf-ts/pond"; import { User } from "../models/user"; import { Team } from "../models/team"; +import { Firmware } from "models"; export interface GlobalState { // tags: Tag[]; user: User; team: Team; as: string; -// firmware: Map; + firmware: Map; // newStructure: boolean; showErrors: boolean; userTeamPermissions: pond.Permission[]; diff --git a/src/providers/index.ts b/src/providers/index.ts index 8c485cb..3b0e784 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -14,7 +14,7 @@ export { // useDeviceWebsocket, // useFieldAPI, // useFieldMarkerAPI, - // useFirmwareAPI, + useFirmwareAPI, useGroupAPI, // useHarvestPlanAPI, // useHarvestYearAPI, @@ -25,7 +25,7 @@ export { usePermissionAPI, // usePreferenceAPI, // useSiteAPI, - // useTagAPI, + useTagAPI, useTeamAPI, useImagekitAPI, // useTaskAPI, diff --git a/src/providers/pond/firmwareAPI.tsx b/src/providers/pond/firmwareAPI.tsx new file mode 100644 index 0000000..e6f8b07 --- /dev/null +++ b/src/providers/pond/firmwareAPI.tsx @@ -0,0 +1,205 @@ +import { useHTTP } from "hooks"; +import { pond } from "protobuf-ts/pond"; +import { useGlobalState } from "providers/StateContainer"; +import { createContext, PropsWithChildren, useContext } from "react"; +import { pondURL } from "./pond"; + +export interface IFirmwareAPIContext { + getFirmwareStatus: (deviceID: number | Long | string, demo?: boolean) => Promise; + cancelUpgrade: (deviceID: number | Long | string) => Promise; + getLatestFirmware: ( + platform: pond.DevicePlatform, + channel: pond.UpgradeChannel, + demo?: boolean + ) => Promise; + getAllLatestFirmware: () => Promise; + listFirmware: ( + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string, + settings?: string, + status?: string + ) => Promise; + removeFirmware: (platform: pond.DevicePlatform, version: string) => Promise; + downloadInstaller: (platform: pond.DevicePlatform, version: string) => Promise; + uploadFirmware: ( + version: string, + channel: pond.UpgradeChannel, + platform: pond.DevicePlatform, + file: Blob, + breaksStorage: boolean + ) => Promise; + updateChannel: ( + platform: pond.DevicePlatform, + version: string, + channel: pond.UpgradeChannel + ) => Promise; + upgradeFirmware: (device: number | string) => Promise; +} + +export const FirmwareAPIContext = createContext({} as IFirmwareAPIContext); + +interface Props {} + +function platformRequest(platform: pond.DevicePlatform): string { + return platformBody(platform) + .toString() + .replace("DEVICE_PLATFORM_", "") + .toLowerCase(); +} + +function channelRequest(channel: pond.UpgradeChannel): string { + return channelBody(channel) + .replace("UPGRADE_CHANNEL_", "") + .toLowerCase(); +} + +function platformBody(platform: pond.DevicePlatform): string { + return pond.DevicePlatform[platform].toString(); +} + +function channelBody(channel: pond.UpgradeChannel): string { + return pond.UpgradeChannel[channel].toString(); +} + +export default function FirmwareProvider(props: PropsWithChildren) { + const { children } = props; + const { get, post, put, del, options } = useHTTP(); + const [{ as }] = useGlobalState(); + + const getFirmwareStatus = (deviceID: number | Long | string, demo: boolean = false) => { + return get(pondURL("/devices/" + deviceID.toString() + "/firmware", demo)); + }; + + const cancelUpgrade = (deviceID: number | Long | string) => { + return post(pondURL("/devices/" + deviceID.toString() + "/cancelUpgrade")); + }; + + const getLatestFirmware = ( + platform: pond.DevicePlatform, + channel: pond.UpgradeChannel, + demo: boolean = false + ) => { + return get( + pondURL( + "/firmware/latest?platform=" + + platformRequest(platform) + + "&channel=" + + channelRequest(channel), + demo + ) + ); + }; + + const getAllLatestFirmware = () => { + return get(pondURL("/firmware/all")); + }; + + const listFirmware = ( + limit: number, + offset: number, + order?: "asc" | "desc", + orderBy?: string, + search?: string, + settings?: string, + status?: string + ) => { + return get( + pondURL( + "/firmware" + + "?limit=" + + limit + + "&offset=" + + offset + + ("&order=" + (order ? order : "asc")) + + ("&by=" + (orderBy ? orderBy : "uploaded")) + + (search ? "&search=" + search : "") + + (settings ? "&settings=" + settings : "") + + (status ? "&status=" + status : "") + ) + ); + }; + + const removeFirmware = (platform: pond.DevicePlatform, version: string) => { + return del(pondURL("/firmware/" + platformRequest(platform) + "/" + version)); + }; + + const downloadInstaller = (platform: pond.DevicePlatform, version: string) => { + let headers = { + Accept: "application/octet-stream", + ...options().headers + }; + headers = { ...headers, ...options().headers }; + let opt = { + responseType: "arraybuffer", + headers: headers + }; + return get( + pondURL("/firmware/download?platform=" + platformRequest(platform) + "&version=" + version), + opt as any + ); + }; + + const uploadFirmware = ( + version: string, + channel: pond.UpgradeChannel, + platform: pond.DevicePlatform, + file: Blob, + breaksStorage: boolean + ) => { + // let headers: { "Content-Type": string } = { + // "Content-Type": "multipart/form-data" + // }; + let headers = { + "Content-Type": "multipart/form-data", + ...options().headers + }; + headers = { ...headers, ...options().headers }; + let opt = { headers }; + let data = new FormData(); + data.append("version", version); + data.append("channel", channelBody(channel)); + data.append("platform", platformBody(platform)); + data.append("file", file); + data.append("breaksStorage", breaksStorage.toString()); + return post(pondURL("/firmware"), data, opt); + }; + + const updateChannel = ( + platform: pond.DevicePlatform, + version: string, + channel: pond.UpgradeChannel + ) => { + return put(pondURL("/firmware/channel"), { + platform: platformBody(platform), + version: version, + channel: channelBody(channel) + }); + }; + + const upgradeFirmware = (device: number | string) => { + return post(pondURL("/devices/" + device + "/upgrade" + (as ? "?as=" + as : "")), {}); + }; + + return ( + + {children} + + ); +} + +export const useFirmwareAPI = () => useContext(FirmwareAPIContext); diff --git a/src/providers/pond/pond.tsx b/src/providers/pond/pond.tsx index 0272de3..a7a2ef4 100644 --- a/src/providers/pond/pond.tsx +++ b/src/providers/pond/pond.tsx @@ -10,6 +10,8 @@ import NoteProvider, { useNoteAPI } from "./noteAPI"; import DeviceProvider, { useDeviceAPI } from "./deviceAPI"; import BackpackProvider, { useBackpackAPI } from "./backpackAPI"; import GroupProvider, { useGroupAPI } from "./groupAPI"; +import TagProvider, { useTagAPI } from "./tagAPI"; +import FirmwareProvider, { useFirmwareAPI } from "./firmwareAPI"; // import NoteProvider from "providers/noteAPI"; export const pondURL = (partial: string, demo: boolean = false): string => { @@ -36,7 +38,11 @@ export default function PondProvider(props: PropsWithChildren) { - {children} + + + {children} + + @@ -52,7 +58,9 @@ export default function PondProvider(props: PropsWithChildren) { export { useBackpackAPI, useDeviceAPI, + useFirmwareAPI, useUserAPI, + useTagAPI, useTeamAPI, useImagekitAPI, usePermissionAPI, diff --git a/src/providers/pond/tagAPI.tsx b/src/providers/pond/tagAPI.tsx new file mode 100644 index 0000000..3995c0b --- /dev/null +++ b/src/providers/pond/tagAPI.tsx @@ -0,0 +1,56 @@ +import { useHTTP } from "hooks"; +import { pond } from "protobuf-ts/pond"; +import { createContext, PropsWithChildren, useContext } from "react"; +import { pondURL } from "./pond"; + +export interface ITagAPIContext { + addTag: (tag: pond.TagSettings) => Promise; + updateTag: (tagID: string, tag: pond.TagSettings) => Promise; + removeTag: (tagID: string) => Promise; + getTag: (tagID: string) => Promise; + listTags: () => Promise; +} + +export const TagAPIContext = createContext({} as ITagAPIContext); + +interface Props {} + +export default function TagProvider(props: PropsWithChildren) { + const { children } = props; + const { get, del, put, post } = useHTTP(); + + const addTag = (tag: pond.TagSettings) => { + return post(pondURL("/tags"), tag); + }; + + const updateTag = (tagID: string, tag: pond.TagSettings) => { + return put(pondURL("/tags/" + tagID), tag); + }; + + const removeTag = (tagID: string) => { + return del(pondURL("/tags/" + tagID)); + }; + + const getTag = (tagID: string) => { + return get(pondURL("/tags/" + tagID)); + }; + + const listTags = () => { + return get(pondURL("/tags")); + }; + + return ( + + {children} + + ); +} + +export const useTagAPI = () => useContext(TagAPIContext);