merged tag stuff

This commit is contained in:
Carter 2025-01-27 12:16:47 -06:00
commit 4f31d036ef
41 changed files with 5028 additions and 126 deletions

View file

@ -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<string, Firmware> = 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 (
<LoadingScreen message='Loading firmwares' />
)
return (
<>
{children}
</>
)
}

View file

@ -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 (
<StateProvider state={global} reducer={reducer}>
<main className={classes.appContent}>
<NavigationContainer toggleTheme={toggleTheme} />
</main>
{/* <FirmwareLoader> */}
<main className={classes.appContent}>
<NavigationContainer toggleTheme={toggleTheme} />
</main>
{/* </FirmwareLoader> */}
</StateProvider>
)
}

View file

@ -0,0 +1,81 @@
import {
Box,
CircularProgress,
SxProps,
Theme,
useTheme
} from "@mui/material";
import React, { ReactElement } from "react";
interface Props {
size?: number | string;
progress?: number;
// color?: OverridableStringUnion<
// 'primary' | 'secondary' | 'error' | 'info' | 'success' | 'warning' | 'inherit',
// CircularProgressPropsColorOverrides
// >;
color?: string;
sx?: SxProps<Theme>,
icon: ReactElement,
}
export default function CircleGraphIcon(props: Props) {
const { progress, icon } = props;
const theme = useTheme()
const size = props.size ? props.size : theme.spacing(5)
const exists = props.color ? props.color in theme.palette : false
const color = exists && props.color ? (theme.palette as any)[props.color].main : props.color
return (
<Box
sx={{
position: 'relative',
display: 'inline-flex',
justifyContent: 'center',
alignItems: 'center',
width: size,
height: size,
marginBottom: theme.spacing(-1),
...props.sx,
}}
>
{/* Circular Graph */}
<CircularProgress
variant="determinate"
value={progress}
// color={color}
thickness={4}
style={{
width: size,
height: size,
position: 'absolute',
}}
sx={{ color: {color} }}
/>
{/* Icon at the center */}
<Box
sx={{
position: 'absolute',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
color: {color}
}}
>
{/* CLone element to apply color and size */}
{icon &&
React.cloneElement(icon, {
// color: {color},
sx: {
width: `calc(${size} - 14px)`,
height: `calc(${size} - 14px)`,
color: {color},
},
})}
</Box>
</Box>
)
}

View file

@ -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 (
<AutoSizer disableHeight>
{({ width }: any) => (
<TwitterPicker
color={colour}
onChangeComplete={colour => onChange(colour.hex)}
width={width}
triangle="hide"
colors={[
"#f44336",
"#e91e63",
"#9c27b0",
"#673ab7",
"#3f51b5",
"#2196f3",
"#03a9f4",
"#00bcd4",
"#009688",
"#4caf50",
"#8bc34a",
"#cddc39",
"#ffeb3b",
"#ffc107",
"#ff9800",
"#ff5722"
]}
/>
)}
</AutoSizer>
);
}

View file

@ -0,0 +1,308 @@
import {
Accordion,
AccordionDetails,
AccordionSummary,
Button,
DialogActions,
DialogContent,
DialogTitle,
Grid2 as Grid,
List,
ListItem,
MenuItem,
TextField
} from "@mui/material";
import { ExpandMore } from "@mui/icons-material";
import { Component } from "models";
import { pond, quack } from "protobuf-ts/pond";
import React, { useCallback, useState, useEffect } from "react";
import ResponsiveDialog from "./ResponsiveDialog";
interface Props {
open: boolean;
existingMutation?: pond.LinearMutation;
componentsByDevice: Map<string, Component[]>; //string being device id and component array of that devices components
onClose: () => void;
onSubmit: (mutation: pond.LinearMutation) => void;
}
export default function LinearMutationBuilder(props: Props) {
const { open, onClose, componentsByDevice, onSubmit, existingMutation } = props;
const [coefficientSets, setCoefficientSets] = useState<pond.ComponentCoefficients[]>([]);
const [devOptions, setDevOptions] = useState<string[]>([]);
const [compOptions, setCompOptions] = useState<Component[][]>([]);
const [selectedDevices, setSelectedDevices] = useState<string[]>([]);
const [selectedComponents, setSelectedComponents] = useState<string[]>([]);
const [selectedMutation, setSelectedMutation] = useState(pond.Mutator.MUTATOR_NONE);
const [mutationConstant, setMutationConstant] = useState(0);
const [mutationName, setMutationName] = useState("");
useEffect(() => {
setDevOptions(Array.from(componentsByDevice.keys()));
setMutationName("");
setSelectedMutation(pond.Mutator.MUTATOR_NONE);
setCoefficientSets([]);
setMutationConstant(0);
setSelectedComponents([]);
setSelectedDevices([]);
setCompOptions([]);
if (existingMutation) {
setMutationName(existingMutation.mutationName);
setSelectedMutation(existingMutation.mutation);
setCoefficientSets(existingMutation.componentCoefficients);
setMutationConstant(existingMutation.constant);
let co: Component[][] = [];
let sc: string[] = [];
let sd: string[] = [];
existingMutation.componentCoefficients.forEach(compCo => {
let splitString = compCo.componentKey.split(":", 2);
if (splitString.length > 1) {
sd.push(splitString[0]);
sc.push(splitString[1]);
let c = componentsByDevice.get(splitString[0]);
if (c) {
co.push(c);
}
}
});
setSelectedComponents(sc);
setSelectedDevices(sd);
setCompOptions(co);
}
}, [componentsByDevice, existingMutation]);
const addNewSet = () => {
let cs = coefficientSets;
let sd = selectedDevices;
let co = compOptions;
let sc = selectedComponents;
cs.push(pond.ComponentCoefficients.create());
sd.push("");
co.push([]);
sc.push("");
setCoefficientSets([...cs]);
setSelectedDevices([...sd]);
setCompOptions([...co]);
setSelectedComponents([...sc]);
};
const setOptionsFor = (pos: number, dev: string) => {
let co = compOptions;
let components = componentsByDevice.get(dev);
if (components) {
co[pos] = components;
}
setCompOptions([...co]);
};
const nodeCoefficientDisplay = (
deviceID: string,
componentKey: string,
set: pond.ComponentCoefficients
) => {
set.componentKey = deviceID + ":" + componentKey; //note: changing the set that is passed in is changing the state variable
let componentList = componentsByDevice.get(deviceID);
let component: Component = Component.create();
if (componentList) {
componentList.forEach(comp => {
if (comp.key() === componentKey) {
component = comp;
}
});
}
let nodeList: JSX.Element[] = [];
component.lastMeasurement.forEach((um, k) => {
if (um.values[0]) {
if (set.measurementCoefficients[k] === undefined) {
set.measurementCoefficients[k] = pond.MeasurementCoefficients.create({
coefficients: [],
measurementType: um.type
});
}
for (let i = 0; i < um.values[0].values.length; i++) {
//default to 0 if it isn't set
if (!set.measurementCoefficients[k].coefficients[i]) {
set.measurementCoefficients[k].coefficients[i] = 0;
}
nodeList.push(
<ListItem key={componentKey + "node" + i + um.type}>
<Grid container direction="row" alignContent="center" alignItems="center">
<Grid size={{ xs: 12 }}>
Node {i + 1} ({quack.MeasurementType[um.type]})
</Grid>
<Grid size={{ xs: 12 }}>
<TextField
fullWidth
value={set.measurementCoefficients[k].coefficients[i]}
type="number"
onChange={e => {
set.measurementCoefficients[k].coefficients[i] = +e.target.value;
setCoefficientSets([...coefficientSets]);
}}
/>
</Grid>
</Grid>
</ListItem>
);
}
}
});
return <List>{nodeList}</List>;
};
const displaySets = useCallback(() => {
let setDisplay: JSX.Element[] = [];
coefficientSets.forEach((set, i) => {
setDisplay.push(
<ListItem key={i} style={{ width: 450 }}>
<Grid container direction="column">
<Grid
container
direction="row"
alignContent="center"
alignItems="center"
justifyContent="space-between">
<Grid size={{ xs: 5 }}>
<TextField
fullWidth
label={"Device"}
select
value={selectedDevices[i]}
onChange={event => {
let sd = selectedDevices;
sd[i] = event.target.value as string;
setSelectedDevices([...sd]);
setOptionsFor(i, event.target.value as string);
}}>
{devOptions.map(dev => (
<MenuItem key={dev} value={dev}>
{dev}
</MenuItem>
))}
</TextField>
</Grid>
<Grid size={{ xs: 5 }}>
<TextField
fullWidth
label={"Component"}
select
value={selectedComponents[i]}
onChange={event => {
let sc = selectedComponents;
sc[i] = event.target.value as string;
setSelectedComponents([...sc]);
set.measurementCoefficients = [];
}}>
{compOptions[i] &&
compOptions[i].map(comp => (
<MenuItem key={comp.key()} value={comp.key()}>
{comp.name()}
</MenuItem>
))}
</TextField>
</Grid>
</Grid>
{selectedComponents[i] !== "" && (
<Grid>
<Accordion>
<AccordionSummary expandIcon={<ExpandMore />}>Node Coefficients</AccordionSummary>
<AccordionDetails>
{nodeCoefficientDisplay(selectedDevices[i], selectedComponents[i], set)}
</AccordionDetails>
</Accordion>
</Grid>
)}
</Grid>
</ListItem>
);
});
return <List>{setDisplay}</List>;
}, [coefficientSets, selectedDevices, compOptions, devOptions, selectedComponents]); // eslint-disable-line react-hooks/exhaustive-deps
return (
<ResponsiveDialog open={open} onClose={() => onClose()}>
<DialogTitle>Build New Mutation</DialogTitle>
<DialogContent>
<TextField
style={{ marginBottom: 10 }}
value={mutationName}
fullWidth
label={"Mutation Name"}
onChange={e => {
setMutationName(e.target.value as string);
}}
/>
<TextField
style={{ marginBottom: 10 }}
value={selectedMutation}
fullWidth
select
label={"Mutation Type"}
onChange={e => {
setSelectedMutation(+e.target.value);
}}>
<MenuItem key={pond.Mutator.MUTATOR_NONE} value={pond.Mutator.MUTATOR_NONE}>
Select Type of Calculated Measurement
</MenuItem>
<MenuItem key={pond.Mutator.MUTATOR_FUEL_LEVEL} value={pond.Mutator.MUTATOR_FUEL_LEVEL}>
Fuel Level
</MenuItem>
</TextField>
<TextField
style={{ marginBottom: 10 }}
value={mutationConstant}
fullWidth
type="number"
label={"Mutation Constant"}
onChange={e => {
setMutationConstant(+e.target.value);
}}
/>
{displaySets()}
<Button variant="contained" color="primary" onClick={addNewSet}>
Add Component
</Button>
</DialogContent>
<DialogActions>
{existingMutation ? (
<React.Fragment>
<Button
color="primary"
onClick={() => {
existingMutation.mutationName = mutationName;
existingMutation.constant = mutationConstant;
existingMutation.mutation = selectedMutation;
existingMutation.componentCoefficients = coefficientSets;
onClose();
}}>
Finish
</Button>
</React.Fragment>
) : (
<React.Fragment>
<Button color="primary" onClick={onClose}>
Close
</Button>
<Button
color="primary"
disabled={selectedMutation === pond.Mutator.MUTATOR_NONE}
onClick={() => {
let newMutation = pond.LinearMutation.create();
newMutation.mutation = selectedMutation;
newMutation.componentCoefficients = coefficientSets;
newMutation.constant = mutationConstant;
newMutation.mutationName = mutationName;
onSubmit(newMutation);
onClose();
}}>
Add Mutation
</Button>
</React.Fragment>
)}
</DialogActions>
</ResponsiveDialog>
);
}

20
src/common/StatusChip.tsx Normal file
View file

@ -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 ? (
<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;
}

62
src/common/Tag.tsx Normal file
View file

@ -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 (
<Chip
label={tag ? tag.name() : "Unknown"}
onClick={onClick}
onDelete={onDelete}
style={{
backgroundColor: colour,
color: isLightBG ? "#000" : "#FFF"
}}
deleteIcon={
<DeleteIcon className={isLightBG ? classes.deleteIconLightBG : classes.deleteIconDarkBG} />
}
/>
);
}

231
src/common/TagSettings.tsx Normal file
View file

@ -0,0 +1,231 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Grid2 as Grid,
TextField,
Theme
} from "@mui/material";
import { Delete as DeleteIcon } from "@mui/icons-material";
import ColourPicker from "common/ColourPicker";
import { Tag as TagModel } from "models";
import { 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 (
<Dialog
open={open}
onClose={() => onClose()}
aria-labelledby="confirm-delete-tag-title"
aria-describedby="confirm-delete-tag-description">
<DialogTitle id="confirm-delete-tag-title">Delete the tag {tagName}?</DialogTitle>
<DialogContent>
<DialogContentText id="confirm-delete-tag-description">
By clicking 'Submit', the tag {tagName} will be deleted; anything associated with it will
longer be able to access it.
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => onClose()} color="primary">
Cancel
</Button>
<Button onClick={() => onSubmit()} color="primary">
Submit
</Button>
</DialogActions>
</Dialog>
);
}
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>(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 (
<Dialog open={open} onClose={() => handleClose()}>
<DialogTitle>
<TagUI tag={tag} />
</DialogTitle>
<DialogContent className={classes.gutter}>
<TextField
label="Label"
value={tag.settings.name}
onChange={event => {
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}
/>
<ColourPicker
colour={tag.settings.colour}
onChange={colour => {
tag.settings.colour = colour;
let newTag = TagModel.clone(tag);
newTag.settings.colour = colour;
setTag(newTag);
}}
/>
</DialogContent>
<DialogActions>
<Grid container justifyContent="space-between" alignItems="center">
<Grid>
{mode !== "add" && (
<DeleteButton
variant="contained"
size="small"
onClick={() => setConfirmDeleteTagOpen(true)}>
<DeleteIcon fontSize="small" />
</DeleteButton>
)}
</Grid>
<Grid>
<Button onClick={() => handleClose()} color="primary">
Close
</Button>
<Button onClick={() => submit()} color="primary">
{mode === "add" ? "Create" : "Update"}
</Button>
</Grid>
</Grid>
</DialogActions>
{mode !== "add" && (
<ConfirmDeleteTag
open={confirmDeleteTagOpen}
onClose={tagDeleted => onConfirmDeleteTag(tagDeleted)}
tag={tag}
/>
)}
</Dialog>
);
}

View file

@ -0,0 +1,60 @@
import moment, { Moment } from "moment";
export type DateRangePreset =
| "live"
| "pastHour"
| "pastTwelveHours"
| "pastDay"
| "pastWeek"
| "pastMonth"
| "selectRange";
export interface DateRange {
start: Moment;
end: Moment;
live?: boolean;
}
export const GetDefaultDateRange = (): DateRange => {
let preset: DateRangePreset = getDefaultPreset();
const now = moment();
let start = moment().subtract(1, "days");
let end = now;
let live = false;
switch (preset) {
case "live":
live = true;
break;
case "pastTwelveHours":
start = moment().subtract(12, "hours");
end = now;
break;
case "pastDay":
start = moment().subtract(1, "days");
end = now;
break;
case "pastWeek":
start = moment().subtract(7, "days");
end = now;
break;
case "pastMonth":
start = moment().subtract(1, "months");
end = now;
break;
default:
//default to past hour
start = moment().subtract(1, "hours");
end = now;
break;
}
return { start, end, live } as DateRange;
};
function getDefaultPreset(): DateRangePreset {
let preset: string | null = localStorage.getItem("dateRangePreset");
return preset ? (preset as DateRangePreset) : "pastHour";
}
export function SetDefaultPreset(preset: DateRangePreset) {
localStorage.setItem("dateRangePreset", preset);
}

View file

@ -0,0 +1,280 @@
import {
Button,
createStyles,
DialogActions,
DialogContent,
FormControl,
InputLabel,
MenuItem,
OutlinedInput,
Select,
Theme
} from "@mui/material";
// import { DateRange, StaticDateRangePicker, DateRangeDelimiter } from "@material-ui/pickers";
import moment, { Moment } from "moment";
import React from "react";
import ReactDOM from "react-dom";
import { DateRangePreset, SetDefaultPreset } from "./DateRange";
import ResponsiveDialog from "common/ResponsiveDialog";
import { withStyles, WithStyles } from "@mui/styles";
const styles = (_theme: Theme) => createStyles({});
interface Props extends WithStyles<typeof styles> {
startDate: Moment;
endDate: Moment;
live?: boolean;
updateDateRange: (start: Moment, end: Moment, live: boolean) => void;
label?: string;
allowLive?: boolean;
}
type DateRange<TDate> = [TDate | null, TDate | null];
interface State {
selectMenuOpen: boolean;
dateRangeSelect: DateRangePreset;
dateRangeDialogOpen: boolean;
labelWidth: number;
dateRange: DateRange<Moment>;
}
class DateSelect extends React.Component<Props, State> {
InputLabelRef: any;
constructor(props: Props) {
super(props);
const { startDate, endDate, live } = props;
this.state = {
selectMenuOpen: false,
dateRangeSelect: this.determineSelectOption(startDate, endDate, live),
dateRangeDialogOpen: false,
labelWidth: 0,
dateRange: [startDate, endDate]
};
}
determineSelectOption = (
startDate?: Moment,
endDate?: Moment,
live?: boolean
): DateRangePreset => {
if (live) return "live";
if (!startDate || !endDate) {
return "pastDay";
}
let hourDiff = moment(endDate)
.diff(moment(startDate), "hours", true)
.toFixed(4);
if (hourDiff === "1.0000") {
return "pastHour";
}
if (hourDiff === "12.0000") {
return "pastTwelveHours";
}
let dayDiff = moment(endDate)
.diff(moment(startDate), "days", true)
.toFixed(4);
if (dayDiff === "1.0000") {
return "pastDay";
}
if (dayDiff === "7.0000") {
return "pastWeek";
}
let monthDiff = moment(endDate)
.diff(moment(startDate), "months", true)
.toFixed(4);
if (monthDiff === "1.0000") {
return "pastMonth";
}
return "selectRange";
};
componentDidMount() {
let labelRef: any = ReactDOM.findDOMNode(this.InputLabelRef);
if (typeof labelRef !== "undefined" && labelRef !== null) {
this.setState({
labelWidth: labelRef.offsetWidth
});
}
}
componentDidUpdate = (prevProps: Props) => {
const { startDate, endDate, live } = this.props;
if (
prevProps.startDate !== startDate ||
prevProps.endDate !== endDate ||
prevProps.live !== live
) {
this.setState({
dateRange: [startDate, endDate],
dateRangeSelect: this.determineSelectOption(startDate, endDate, live)
});
}
};
changeDateRangeSelect = (selection: DateRangePreset) => {
let dateRangeDialogOpen = false;
let start, end;
switch (selection) {
case "live":
SetDefaultPreset(selection);
this.props.updateDateRange(moment(), moment(), true);
break;
case "pastHour":
SetDefaultPreset(selection);
start = moment().subtract(1, "hours");
end = moment();
this.props.updateDateRange(start, end, false);
break;
case "pastTwelveHours":
SetDefaultPreset(selection);
start = moment().subtract(12, "hours");
end = moment();
this.props.updateDateRange(start, end, false);
break;
case "pastDay":
SetDefaultPreset(selection);
start = moment().subtract(1, "days");
end = moment();
this.props.updateDateRange(start, end, false);
break;
case "pastWeek":
SetDefaultPreset(selection);
start = moment().subtract(7, "days");
end = moment();
this.props.updateDateRange(start, end, false);
break;
case "pastMonth":
SetDefaultPreset(selection);
start = moment().subtract(1, "months");
end = moment();
this.props.updateDateRange(start, end, false);
break;
case "selectRange":
dateRangeDialogOpen = true;
break;
default:
break;
}
this.setState({
dateRangeSelect: selection,
dateRangeDialogOpen: dateRangeDialogOpen
});
};
updateDateRange = (dateRange: DateRange<Moment>) => {
this.setState({ dateRange });
};
submitCustomRange = () => {
const { dateRange } = this.state;
const startDate = dateRange[0] ? dateRange[0].clone() : moment();
const endDate = dateRange[1] ? dateRange[1].clone() : moment();
this.props.updateDateRange(startDate, endDate, false);
};
handleSubmitDateRange = () => {
this.submitCustomRange();
this.closeDateRangeDialog();
};
closeDateRangeDialog = () => {
this.setState({ dateRangeDialogOpen: false });
};
customRangeLabel = (dateRange: DateRange<Moment>) => {
const startDate = dateRange[0] ? dateRange[0].clone() : moment();
const endDate = dateRange[1] ? dateRange[1].clone() : moment();
const sameYear = startDate.get("year") === endDate.get("year");
return sameYear
? startDate.format("MMM Do") + " to " + endDate.format("MMM Do")
: startDate.format("MMM Do, Y") + " to " + endDate.format("MMM Do, Y");
};
openSelectMenu = () => {
this.setState({ selectMenuOpen: true });
};
closeSelectMenu = () => {
this.setState({ selectMenuOpen: false });
};
render() {
const { label, allowLive } = this.props;
const {
selectMenuOpen,
dateRangeSelect,
dateRangeDialogOpen,
dateRange,
} = this.state;
return (
<React.Fragment>
<FormControl margin="normal" variant="outlined" fullWidth>
<InputLabel
ref={ref => {
this.InputLabelRef = ref;
}}
htmlFor="select-date-range">
{label !== undefined ? label : "Show"}
</InputLabel>
<Select
open={selectMenuOpen}
onClose={this.closeSelectMenu}
onOpen={this.openSelectMenu}
value={dateRangeSelect}
onChange={event => this.changeDateRangeSelect(event.target.value as DateRangePreset)}
input={<OutlinedInput /*labelWidth={labelWidth}*/ />}>
{allowLive && <MenuItem value="live">Live</MenuItem>}
<MenuItem value="pastHour">Past Hour</MenuItem>
<MenuItem value="pastTwelveHours">Past 12 Hours</MenuItem>
<MenuItem value="pastDay">Past Day</MenuItem>
<MenuItem value="pastWeek">Past Week</MenuItem>
<MenuItem value="pastMonth">Past Month</MenuItem>
<MenuItem value="selectRange">
{selectMenuOpen ? "Select Range" : this.customRangeLabel(dateRange)}
</MenuItem>
</Select>
</FormControl>
<ResponsiveDialog
open={dateRangeDialogOpen}
onClose={this.closeDateRangeDialog}
aria-labelledby="date-range-dialog">
<DialogContent>
{/* <StaticDateRangePicker
disableFuture
value={dateRange}
onChange={date => this.updateDateRange(date)}
renderInput={(startProps, endProps) => (
<React.Fragment>
<TextField {...startProps} />
<DateRangeDelimiter> to </DateRangeDelimiter>
<TextField {...endProps} />
</React.Fragment>
)}
/> */}
</DialogContent>
<DialogActions>
<Button onClick={this.closeDateRangeDialog} color="primary">
Close
</Button>
<Button onClick={this.handleSubmitDateRange} color="primary">
Submit
</Button>
</DialogActions>
</ResponsiveDialog>
</React.Fragment>
);
}
}
export default withStyles(styles)(DateSelect);

View file

@ -0,0 +1,79 @@
import { InputAdornment, MenuItem, TextField } from "@mui/material";
import { usePrevious } from "hooks";
import { useEffect, useState } from "react";
import { TimeUnit, milliToX, bestUnit, xToMilli } from "./duration";
interface Props {
onChange: (ms: number) => void;
units: TimeUnit[];
initialMs?: number;
id?: string;
label?: string;
isDisabled?: boolean;
isError?: boolean;
helperText?: string;
}
export default function PeriodSelect(props: Props) {
const { onChange, id, label, isDisabled, isError, initialMs, helperText, units } = props;
let initialUnit: TimeUnit = bestUnit(initialMs);
let initialValue = milliToX(initialMs, initialUnit).toString();
const [value, setValue] = useState<string>(initialValue);
const [unit, setUnit] = useState<TimeUnit>(initialUnit);
const prevUnit = usePrevious(unit);
useEffect(() => {
if (prevUnit !== unit) {
onChange(xToMilli(value, unit));
}
}, [unit, prevUnit, onChange, value]);
const changeValue = (event: any) => {
let value = event.target.value;
setValue(value);
onChange(isNaN(value) ? 0 : xToMilli(value, unit));
};
const selectText = (event: any) => {
event.target.select();
};
return (
<TextField
id={id}
label={label}
disabled={isDisabled === true}
error={isError === true}
value={value}
onChange={changeValue}
onFocus={selectText}
margin="normal"
fullWidth
helperText={helperText}
type="text"
variant="outlined"
InputLabelProps={{ shrink: true }}
InputProps={{
endAdornment: (
<InputAdornment position="end">
<TextField
id="unitSelect"
disabled={isDisabled === true}
error={isError === true}
select
value={unit}
onChange={event => setUnit(event.target.value as TimeUnit)}
margin="none"
variant="standard">
{units.includes("ms") && <MenuItem value="milliseconds">ms</MenuItem>}
{units.includes("seconds") && <MenuItem value="seconds">sec</MenuItem>}
{units.includes("minutes") && <MenuItem value="minutes">min</MenuItem>}
{units.includes("hours") && <MenuItem value="hours">hrs</MenuItem>}
{units.includes("days") && <MenuItem value="days">days</MenuItem>}
</TextField>
</InputAdornment>
)
}}
/>
);
}

202
src/common/time/TimeBar.tsx Normal file
View file

@ -0,0 +1,202 @@
import {
Button,
DialogActions,
DialogContent,
Grid,
TextField,
Typography
} from "@material-ui/core";
import { createStyles, makeStyles, Theme } from "@material-ui/core/styles";
import moment, { Moment } from "moment";
import { DateRange as DateIcon } from "@material-ui/icons";
import React, { useEffect, useState } from "react";
import ResponsiveDialog from "common/ResponsiveDialog";
import { DateRange, DateRangeDelimiter, StaticDateRangePicker } from "@material-ui/pickers";
import { DateRangePreset, GetDefaultDateRange, SetDefaultPreset } from "./DateRange";
import { useThemeType } from "hooks";
interface Props {
startDate: Moment;
endDate: Moment;
updateDateRange: (start: Moment, end: Moment, live: boolean) => void;
}
const useStyles = makeStyles((theme: Theme) =>
createStyles({
activeButtonLight: {
border: "1px solid black"
},
acitveButtonDark: {
border: "1px solid white"
}
})
);
export default function TimeBar(props: Props) {
const { updateDateRange, startDate, endDate } = props;
const defaultDateRange = GetDefaultDateRange();
const [dateRangeDialog, setDateRangeDialog] = useState(false);
const [dateRange, setDateRange] = useState<DateRange<Moment>>([
defaultDateRange.start,
defaultDateRange.end
]);
const [activeView, setActiveView] = useState<DateRangePreset>();
const classes = useStyles();
const themeType = useThemeType();
const submitDateRange = () => {
const startDate = dateRange[0] ? dateRange[0].clone() : moment();
const endDate = dateRange[1] ? dateRange[1].clone() : moment();
updateDateRange(startDate, endDate, false);
setDateRangeDialog(false);
setActiveView("selectRange");
};
useEffect(() => {
let hourDiff = moment(endDate)
.diff(moment(startDate), "hours", true)
.toFixed(4);
let dayDiff = moment(endDate)
.diff(moment(startDate), "days", true)
.toFixed(4);
let monthDiff = moment(endDate)
.diff(moment(startDate), "months", true)
.toFixed(4);
if (hourDiff === "1.0000") {
setActiveView("pastHour");
} else if (dayDiff === "1.0000") {
setActiveView("pastDay");
} else if (dayDiff === "7.0000") {
setActiveView("pastWeek");
} else if (monthDiff === "1.0000") {
setActiveView("pastMonth");
} else {
setActiveView("selectRange");
}
}, [startDate, endDate]);
const datePickerDialog = () => {
return (
<ResponsiveDialog
open={dateRangeDialog}
onClose={() => setDateRangeDialog(false)}
aria-labelledby="date-range-dialog">
<DialogContent>
<StaticDateRangePicker
disableFuture
value={dateRange}
onChange={date => setDateRange(date)}
renderInput={(startProps, endProps) => (
<React.Fragment>
<TextField {...startProps} />
<DateRangeDelimiter> to </DateRangeDelimiter>
<TextField {...endProps} />
</React.Fragment>
)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDateRangeDialog(false)} color="primary">
Close
</Button>
<Button onClick={submitDateRange} color="primary">
Submit
</Button>
</DialogActions>
</ResponsiveDialog>
);
};
return (
<React.Fragment>
{datePickerDialog()}
<Grid container direction="row" spacing={1} wrap="nowrap">
<Grid item>
<Button
style={{ borderRadius: "25px" }}
className={
activeView === "pastHour"
? themeType === "light"
? classes.activeButtonLight
: classes.acitveButtonDark
: ""
}
onClick={() => {
updateDateRange(moment().subtract(1, "hour"), moment(), false);
SetDefaultPreset("pastHour");
}}>
<Typography style={{ fontWeight: 650 }}>1H</Typography>
</Button>
</Grid>
<Grid item>
<Button
style={{ borderRadius: "25px" }}
className={
activeView === "pastDay"
? themeType === "light"
? classes.activeButtonLight
: classes.acitveButtonDark
: ""
}
onClick={() => {
updateDateRange(moment().subtract(1, "day"), moment(), false);
SetDefaultPreset("pastDay");
}}>
<Typography style={{ fontWeight: 650 }}>1D</Typography>
</Button>
</Grid>
<Grid item>
<Button
style={{ borderRadius: "25px" }}
className={
activeView === "pastWeek"
? themeType === "light"
? classes.activeButtonLight
: classes.acitveButtonDark
: ""
}
onClick={() => {
updateDateRange(moment().subtract(1, "week"), moment(), false);
SetDefaultPreset("pastWeek");
}}>
<Typography style={{ fontWeight: 650 }}>1W</Typography>
</Button>
</Grid>
<Grid item>
<Button
style={{ borderRadius: "25px" }}
className={
activeView === "pastMonth"
? themeType === "light"
? classes.activeButtonLight
: classes.acitveButtonDark
: ""
}
onClick={() => {
updateDateRange(moment().subtract(1, "month"), moment(), false);
SetDefaultPreset("pastMonth");
}}>
<Typography style={{ fontWeight: 650 }}>1M</Typography>
</Button>
</Grid>
<Grid item>
<Button
style={{ borderRadius: "25px" }}
className={
activeView === "selectRange"
? themeType === "light"
? classes.activeButtonLight
: classes.acitveButtonDark
: ""
}
onClick={() => {
setDateRangeDialog(true);
}}>
<DateIcon />
</Button>
</Grid>
</Grid>
</React.Fragment>
);
}

View file

@ -0,0 +1,53 @@
export type TimeUnit = "ms" | "seconds" | "minutes" | "hours" | "days";
export function milliToX(ms: number | undefined, unit: TimeUnit): number {
if (!ms || isNaN(ms)) {
return 0;
}
switch (unit) {
case "days":
return parseFloat(ms.toString()) / 86400000;
case "hours":
return parseFloat(ms.toString()) / 3600000;
case "minutes":
return parseFloat(ms.toString()) / 60000;
case "seconds":
return parseFloat(ms.toString()) / 1000;
default:
return parseFloat(ms.toString());
}
}
export function xToMilli(value: string, unit: TimeUnit): number {
if (isNaN(parseInt(value)) || value === "") {
return 0;
}
switch (unit) {
case "days":
return parseFloat(value.toString()) * 86400000;
case "hours":
return parseFloat(value.toString()) * 3600000;
case "minutes":
return parseFloat(value.toString()) * 60000;
case "seconds":
return parseFloat(value.toString()) * 1000;
default:
return parseInt(value);
}
}
export function bestUnit(ms?: number): TimeUnit {
if (!ms || ms === 0) {
return "minutes";
}
return milliToX(ms, "days") >= 1
? "days"
: milliToX(ms, "hours") >= 1
? "hours"
: milliToX(ms, "minutes") >= 1
? "minutes"
: milliToX(ms, "seconds") >= 1
? "seconds"
: "ms";
}

27
src/common/time/locale.ts Normal file
View file

@ -0,0 +1,27 @@
import moment from "moment";
type Language = "en";
export const LoadLocale = (language: Language) => {
switch (language) {
default:
moment.updateLocale("en", {
relativeTime: {
future: "in %s",
past: "%s ago",
s: "seconds",
ss: "%ss",
m: "a minute",
mm: "%dmin",
h: "an hour",
hh: "%dh",
d: "a day",
dd: "%dd",
M: "a month",
MM: "%dmo",
y: "a year",
yy: "%d years"
}
});
}
};

View file

@ -27,8 +27,8 @@ import {
Wifi as WifiIcon
} from "@mui/icons-material";
import { Skeleton } from "@mui/material";
import Datadog from "assets/external/datadog.png";
import { ImgIcon } from "common/ImgIcon";
// import Datadog from "assets/external/datadog.png";
// import { ImgIcon } from "common/ImgIcon";
import NotificationButton from "common/NotificationButton";
// import ComponentOrder from "component/ComponentOrder";
// import ComponentSettings from "component/ComponentSettings";
@ -44,7 +44,7 @@ import { cloneDeep } from "lodash";
// import { Component, Device, deviceScope, Interaction } from "models";
import { Device, deviceScope } from "models";
// import { MatchParams } from "navigation/Routes";
import { isShareableLink } from "pbHelpers/Device";
// import { isShareableLink } from "pbHelpers/Device";
import { pond } from "protobuf-ts/pond";
import { useGlobalState } from "providers";
import React, { useState } from "react";
@ -61,8 +61,10 @@ import ShareObject from "user/ShareObject";
import { amber, blue, green } from "@mui/material/colors";
import { makeStyles } from "@mui/styles";
import { useNavigate } from "react-router-dom";
import DeviceSettings from "device/DeviceSettings";
import ObjectTeams from "teams/ObjectTeams";
const useStyles = makeStyles((theme: Theme) => {
const useStyles = makeStyles((_theme: Theme) => {
// const isMobile = useMobile()
return ({
greenIcon: {
@ -373,14 +375,15 @@ export default function DeviceActions(props: Props) {
return (
<React.Fragment>
{/* <DeviceSettings
<DeviceSettings
device={device}
isDialogOpen={or(isDeviceSettingsDialogOpen, false)}
closeDialogCallback={() => closeDialog("isDeviceSettingsDialogOpen")}
refreshCallback={refreshCallback}
canEdit={canWrite}
components={components}
/> */}
// components={components}
components={[]}
/>
{/* <ComponentSettings
mode="add"
device={device}
@ -421,14 +424,14 @@ export default function DeviceActions(props: Props) {
closeDialogCallback={() => closeDialog("isObjectUsersDialogOpen")}
refreshCallback={refreshCallback}
/>
{/* <ObjectTeams
<ObjectTeams
scope={deviceScope(device.id().toString())}
label={deviceName}
permissions={permissions}
isDialogOpen={or(isObjectTeamsDialogOpen, false)}
closeDialogCallback={() => closeDialog("isObjectTeamsDialogOpen")}
refreshCallback={refreshCallback}
/> */}
/>
<RemoveSelfFromObject
scope={deviceScope(device.id().toString())}
label={deviceName}

View file

@ -0,0 +1,239 @@
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;
tags: pond.Tag[];
}
export default function DeviceOverview(props: Props) {
const [{ user, firmware }] = useGlobalState();
const { device, components, usage, loading, disableAddTag, tags } = props;
const prevComponents = usePrevious(components);
const { info } = useSnackbar();
const classes = useStyles();
const width = useWidth();
const now = moment();
const navigate = useNavigate();
// const match = useRouteMatch<MatchParams>();
const [powerComponent, setPowerComponent] = useState<Component | undefined | null>();
const [modemComponent, setModemComponent] = useState<Component | undefined | null>();
// 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 (
<Grid
container
direction="row"
alignItems="center"
spacing={1}
wrap={width === "xs" ? "nowrap" : "wrap"}
className={width === "xs" ? classes.chipContainer : undefined}>
{device.settings.platform > 0 && (
<Grid>
<Tooltip title={connectivity.tooltip}>
<Chip
variant={modemComponent ? "filled" : "outlined"}
clickable={modemComponent !== null}
onClick={() => {
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 ? <Launch /> : <React.Fragment />}
/>
</Tooltip>
</Grid>
)}
{notNull(device.status.power) && (
<Grid>
<Tooltip title={power.description}>
<Chip
variant={powerComponent ? "filled" : "outlined"}
clickable={powerComponent !== null}
onClick={() => {
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 ? <Launch /> : <React.Fragment />}
/>
</Tooltip>
</Grid>
)}
{hasVersion && (
<Grid>
<VersionChip version={currentVersion} available={latestFirmware} />
</Grid>
)}
{isCellular && iccid && (
<Grid>
<Tooltip title={isPaused ? "Data paused" : "Data enabled"}>
<Chip
onClick={() => copySim(iccid)}
label={"SIM " + iccid.substring(iccid.length - 5)}
icon={<SimCard />}
/>
</Tooltip>
</Grid>
)}
{isCellular && usage && usage.bytes > 0 && (
<Grid>
<Tooltip title="Estimated usage in the past 24 hours">
<Chip
variant="outlined"
label={usageDesc}
icon={<DataUsage />}
clickable={canHologram}
onClick={() => {
// if (canHologram) setHologramDialog(true);
}}
/>
</Tooltip>
</Grid>
)}
{!device.status.synced && (
<Grid>
<StatusChip status="pending" />
</Grid>
)}
{user.allowedTo("provision") && <DeviceTags tags={tags} device={device} disableAdd={disableAddTag} />}
</Grid>
);
};
return (
<React.Fragment>
{loading ? <Skeleton variant="text" width={width === "xs" ? 250 : 350} /> : chips()}
{/* <DeviceHologram device={device} open={hologramDialog} setOpen={setHologramDialog} /> */}
</React.Fragment>
);
}

View file

@ -0,0 +1,680 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
Grid2 as Grid,
List,
ListItem,
MenuItem,
Switch,
Tab,
Tabs,
TextField,
Theme,
Typography
} from "@mui/material";
// import { Theme } from "@material-ui/core/styles/createMuiTheme";
import DeleteButton from "common/DeleteButton";
import PeriodSelect from "common/time/PeriodSelect";
import ResponsiveDialog from "common/ResponsiveDialog";
import { useDeviceAPI, usePrevious, useSnackbar } from "hooks";
import { Component, Device } from "models";
import { IsExtended, ListDeviceProductDescribers } from "products/DeviceProduct";
import { pond } from "protobuf-ts/pond";
import { useGlobalState } from "providers";
import React, { useEffect, useState } from "react";
// import { useHistory } from "react-router";
import { quack } from "protobuf-ts/quack";
import LinearMutationBuilder from "common/LinearMutationBuilder";
import { makeStyles } from "@mui/styles";
import { useNavigate } from "react-router-dom";
interface TabPanelProps {
children?: React.ReactNode;
index: any;
value: any;
}
function TabPanelMine(props: TabPanelProps) {
const { children, value, index, ...other } = props;
return (
<div
role="tabpanel"
hidden={value !== index}
aria-labelledby={`simple-tab-${index}`}
{...other}>
{value === index && <React.Fragment>{children}</React.Fragment>}
</div>
);
}
const useStyles = makeStyles((theme: Theme) => {
return ({
dialogContent: {
minHeight: "25vh"
},
removeDeviceDialog: {
zIndex: theme.zIndex.modal + 1
},
tabSmall: {
width: "100%",
boxSizing: "border-box",
flexShrink: 1,
minWidth: "48px",
marginTop: 0,
paddingTop: 0
}
})
});
interface Props {
device: Device;
isDialogOpen: boolean;
closeDialogCallback: Function;
canEdit: boolean;
refreshCallback: Function;
components?: Component[];
}
const deviceFromForm = (device: Device): Device => {
if (device.settings.upgradeChannel === pond.UpgradeChannel.UPGRADE_CHANNEL_INVALID) {
device.settings.upgradeChannel = pond.UpgradeChannel.UPGRADE_CHANNEL_STABLE;
}
return device;
};
export default function DeviceSettings(props: Props) {
const classes = useStyles();
const [{ user }] = useGlobalState();
const navigate = useNavigate();
const { success, error } = useSnackbar();
const deviceAPI = useDeviceAPI();
const { device, isDialogOpen, closeDialogCallback, canEdit, refreshCallback, components } = props;
const prevDevice = usePrevious(device);
const [deviceForm, setDeviceForm] = useState<Device>(deviceFromForm(Device.clone(device)));
const [isRemoveDeviceDialogOpen, setIsRemoveDeviceDialogOpen] = useState<boolean>(false);
const [sleeps, setSleeps] = useState<boolean>(device.settings.sleepDurationS > 0);
const [compExtOne, setCompExtOne] = useState("");
const [compExtTwo, setCompExtTwo] = useState("");
const [compExtThree, setCompExtThree] = useState("");
const [currentTab, setCurrentTab] = useState(0);
const [linearMutations, setLinearMutations] = useState<pond.LinearMutation[]>([]);
const [componentsByDevice, setComponentsByDevice] = useState<Map<string, Component[]>>(
new Map<string, Component[]>()
);
const [newMutationDialog, setNewMutationDialog] = useState(false);
const [existingMutation, setExistingMutation] = useState<pond.LinearMutation>();
useEffect(() => {
if (prevDevice !== device) {
setDeviceForm(deviceFromForm(Device.clone(props.device)));
if (device.settings.extensionComponents[0]) {
setCompExtOne(device.settings.extensionComponents[0]);
}
if (device.settings.extensionComponents[1]) {
setCompExtTwo(device.settings.extensionComponents[1]);
}
if (device.settings.extensionComponents[2]) {
setCompExtThree(device.settings.extensionComponents[2]);
}
if (device.settings.mutations) {
setLinearMutations(device.settings.mutations);
}
if (components) {
let cbd = new Map<string, Component[]>();
cbd.set(device.id().toString(), components);
setComponentsByDevice(cbd);
}
}
}, [device, prevDevice, props.device, components]);
const close = () => {
closeDialogCallback();
};
const isSleepDurationValid = (device: Device): boolean => {
return (
!sleeps ||
(sleeps && device.settings.sleepDurationS >= 10 && device.settings.sleepDelayMs >= 1000)
);
};
const minCheckPeriodS = (): number => {
let defaultPeriod = 60;
switch (device.settings.platform) {
case pond.DevicePlatform.DEVICE_PLATFORM_ELECTRON:
return user.hasFeature("admin") ? defaultPeriod : 300;
default:
return defaultPeriod;
}
};
const minCheckPeriodDescription = (): string => {
let min = minCheckPeriodS() / 60;
return min.toString() + " minute" + (min !== 1 ? "s" : "");
};
const isCheckPeriodValid = (device: Device): boolean => {
return (
device.settings.pondCheckPeriodS >= minCheckPeriodS() &&
device.settings.pondCheckPeriodS <= 3600
);
};
const isFormValid = (): boolean => {
return isSleepDurationValid(deviceForm) && isCheckPeriodValid(deviceForm);
};
const changeName = (event: any) => {
let updatedForm = Device.clone(deviceForm);
updatedForm.settings.name = String(event.target.value);
setDeviceForm(updatedForm);
};
const changeDescription = (event: any) => {
let updatedForm = Device.clone(deviceForm);
updatedForm.settings.description = String(event.target.value);
setDeviceForm(updatedForm);
};
const changeSleepDuration = (ms: number) => {
let updatedForm = Device.clone(deviceForm);
updatedForm.settings.sleepDurationS = ms / 1000;
setDeviceForm(updatedForm);
};
const changeSleepDelay = (ms: number) => {
let updatedForm = Device.clone(deviceForm);
updatedForm.settings.sleepDelayMs = ms;
setDeviceForm(updatedForm);
};
const changePondCheckPeriod = (ms: number) => {
let updatedForm = Device.clone(deviceForm);
updatedForm.settings.pondCheckPeriodS = ms / 1000;
setDeviceForm(updatedForm);
};
const changeUpgradeChannel = (event: any) => {
let updatedForm = Device.clone(deviceForm);
updatedForm.settings.upgradeChannel = event.target.value;
setDeviceForm(updatedForm);
};
const changeProduct = (event: any) => {
let updatedForm = Device.clone(deviceForm);
updatedForm.settings.product = event.target.value;
setDeviceForm(updatedForm);
};
const upgradeChannelHelper = (): string => {
switch (deviceForm.settings.upgradeChannel) {
case pond.UpgradeChannel.UPGRADE_CHANNEL_STABLE: {
return "";
}
case pond.UpgradeChannel.UPGRADE_CHANNEL_BETA: {
return "The newest field tested features as they become available";
}
case pond.UpgradeChannel.UPGRADE_CHANNEL_ALPHA: {
return "The newest lab tested features as they become available";
}
case pond.UpgradeChannel.UPGRADE_CHANNEL_DEVELOPMENT: {
return "Features as they're written, expect things to break";
}
}
return "";
};
const toggleAutomaticallyUpgrade = (event: any) => {
let updatedForm = Device.clone(deviceForm);
updatedForm.settings.automaticallyUpgrade = event.target.checked;
setDeviceForm(updatedForm);
};
const toggleSleeps = (event: any) => {
let updatedForm = Device.clone(deviceForm);
let updatedSleeps = event.target.checked;
updatedSleeps
? (updatedForm.settings.sleepType = quack.SleepType.SLEEP_TYPE_NAP)
: (updatedForm.settings.sleepType = quack.SleepType.SLEEP_TYPE_NONE);
setSleeps(updatedSleeps);
setDeviceForm(updatedForm);
};
const submitSettings = () => {
const deviceName = deviceForm.name();
const deviceID = device.id();
if (!sleeps) {
deviceForm.settings.sleepDurationS = 0;
}
deviceForm.settings.extensionComponents = [compExtOne, compExtTwo, compExtThree];
deviceForm.settings.mutations = linearMutations;
deviceAPI
.update(deviceID, deviceForm.settings)
.then((_response: any) => {
success(deviceName + " was successfully updated!");
close();
refreshCallback();
})
.catch((_err: any) => {
error("Error occured while configuring " + deviceName);
close();
});
};
const confirmRemoveDevice = () => {
setIsRemoveDeviceDialogOpen(true);
};
const closeConfirmRemoveDeviceDialog = () => {
setIsRemoveDeviceDialogOpen(false);
};
const removeDevice = () => {
const deviceName = deviceForm.name();
deviceAPI
.remove(device.id())
.then((_response: any) => {
success(deviceName + " was successfully deleted!");
navigate("/");
})
.catch((_err: any) => {
error("Error occured while deleting " + deviceName + ".");
closeConfirmRemoveDeviceDialog();
close();
});
};
const canRemove = (): boolean => {
return user.allowedTo("remove-devices");
};
const title = () => {
return (
<React.Fragment>
Device Settings
<Typography variant="body2" color="textSecondary">
{deviceForm.name()} - ID: {deviceForm.id()}
</Typography>
</React.Fragment>
);
};
const content = () => {
return (
<React.Fragment>
<TextField
id="name"
name="name"
label="Name"
defaultValue={deviceForm.settings.name}
onChange={changeName}
margin="normal"
variant="outlined"
type="text"
fullWidth
InputLabelProps={{ shrink: true }}
disabled={!canEdit}
/>
<TextField
id="description"
name="description"
label="Description"
defaultValue={deviceForm.settings.description}
onChange={changeDescription}
multiline
rows={2}
// rowsMax={4}
type="text"
margin="normal"
variant="outlined"
fullWidth
InputLabelProps={{ shrink: true }}
disabled={!canEdit}
/>
<Grid container direction="row" justifyContent="space-between" alignItems="center" spacing={1}>
<Grid size={{ xs: 12, sm: 12 }}>
<PeriodSelect
id="pondCheckPeriod"
label="Check-in Period"
isError={!isCheckPeriodValid(deviceForm)}
helperText={
isCheckPeriodValid(deviceForm)
? "How often the device checks for updates"
: "Value must be between " + minCheckPeriodDescription() + " and 1 hour"
}
initialMs={deviceForm.settings.pondCheckPeriodS * 1000}
isDisabled={!canEdit}
onChange={changePondCheckPeriod}
units={["seconds", "minutes", "hours"]}
/>
</Grid>
{user.hasFeature("sleep") && (
<Grid container direction="row" justifyContent="space-between" alignItems="center" spacing={1}>
<Grid container size={{ xs: 4, sm: 3 }} justifyContent="center">
<FormControlLabel
control={
<Switch
checked={sleeps}
onChange={toggleSleeps}
name="sleeps"
aria-label="sleeps"
color="secondary"
/>
}
label={<Typography variant="caption">Sleeps</Typography>}
labelPlacement="top"
disabled={!canEdit}
/>
</Grid>
<Grid size={{ xs: 8, sm: 9 }}>
<FormControl fullWidth>
<PeriodSelect
id="sleepDuration"
label="Sleep Duration"
isError={!isSleepDurationValid(deviceForm)}
helperText={
!isSleepDurationValid(deviceForm) ? "Must be at least 10 seconds" : undefined
}
isDisabled={!canEdit || !sleeps}
initialMs={deviceForm.settings.sleepDurationS * 1000}
onChange={changeSleepDuration}
units={["seconds", "minutes", "hours"]}
/>
</FormControl>
<FormControl fullWidth>
<PeriodSelect
id="awakeDuration"
label="Awake Duration"
isError={!isSleepDurationValid(deviceForm)}
helperText={
!isSleepDurationValid(deviceForm) ? "Must be at least 1 second" : undefined
}
isDisabled={!canEdit || !sleeps}
initialMs={deviceForm.settings.sleepDelayMs}
onChange={changeSleepDelay}
units={["seconds", "minutes", "hours"]}
/>
</FormControl>
</Grid>
</Grid>
)}
<Grid size={{ xs: 12, sm: 12 }}>
<TextField
id="upgradeChannel"
label="Upgrade Channel"
select
value={deviceForm.settings.upgradeChannel}
onChange={changeUpgradeChannel}
helperText={upgradeChannelHelper()}
margin="normal"
fullWidth
variant="outlined"
disabled={!canEdit}>
<MenuItem value={pond.UpgradeChannel.UPGRADE_CHANNEL_STABLE}>Stable</MenuItem>
<MenuItem value={pond.UpgradeChannel.UPGRADE_CHANNEL_BETA}>Beta</MenuItem>
<MenuItem value={pond.UpgradeChannel.UPGRADE_CHANNEL_ALPHA}>Alpha</MenuItem>
{user.hasFeature("dev-channel") && (
<MenuItem value={pond.UpgradeChannel.UPGRADE_CHANNEL_DEVELOPMENT}>
Development
</MenuItem>
)}
{user.hasFeature("dev-channel") && (
<MenuItem value={pond.UpgradeChannel.UPGRADE_CHANNEL_RECOVERY}>Recovery</MenuItem>
)}
</TextField>
</Grid>
{user.hasFeature("admin") && (
<TextField
id="deviceProduct"
label="Device Product"
select
value={deviceForm.settings.product}
onChange={event => changeProduct(event)}
margin="normal"
fullWidth
variant="outlined"
disabled={!canEdit}>
{ListDeviceProductDescribers().map(describer => (
<MenuItem key={describer.product} value={describer.product}>
{describer.label}
</MenuItem>
))}
</TextField>
)}
{IsExtended(deviceForm.settings.product) && components && (
<Grid size={{ xs: 12 }}>
Select components to display on card
<TextField
id="component1"
label="Component 1"
select
value={compExtOne}
onChange={event => setCompExtOne(event.target.value)}
margin="normal"
fullWidth
variant="outlined"
disabled={!canEdit}>
{components.map(comp => (
<MenuItem key={comp.key()} value={comp.locationString()}>
{comp.name()}
</MenuItem>
))}
</TextField>
<TextField
id="component1"
label="Component 2"
select
value={compExtTwo}
onChange={event => setCompExtTwo(event.target.value)}
margin="normal"
fullWidth
variant="outlined"
disabled={!canEdit}>
{components.map(comp => (
<MenuItem key={comp.key()} value={comp.locationString()}>
{comp.name()}
</MenuItem>
))}
</TextField>
<TextField
id="component1"
label="Component 3"
select
value={compExtThree}
onChange={event => setCompExtThree(event.target.value)}
margin="normal"
fullWidth
variant="outlined"
disabled={!canEdit}>
{components.map(comp => (
<MenuItem key={comp.key()} value={comp.locationString()}>
{comp.name()}
</MenuItem>
))}
</TextField>
</Grid>
)}
{user.hasAdmin() && (
<Grid size={{ xs: 12, sm: 12 }}>
<FormControlLabel
control={
<Switch
checked={deviceForm.settings.automaticallyUpgrade}
onChange={toggleAutomaticallyUpgrade}
name="automaticallyUpgrade"
aria-label="automaticallyUpgrade"
color="secondary"
/>
}
disabled={!canEdit}
label="Automatically Upgrade"
labelPlacement="end"
/>
</Grid>
)}
</Grid>
</React.Fragment>
);
};
const mutationsContent = () => {
let mutations: JSX.Element[] = [];
if (device.settings.mutations) {
device.settings.mutations.forEach((mut, i) => {
mutations.push(
<ListItem key={"mutation" + i}>
<Grid
container
direction="row"
alignContent="center"
alignItems="center"
justifyContent="space-between">
<Grid size={{ xs: 5 }}>
<Typography>{mut.mutationName}</Typography>
</Grid>
<Grid>
<Button
variant="contained"
style={{ backgroundColor: "red" }}
onClick={() => {
let lm = linearMutations;
lm.splice(i, 1);
setLinearMutations([...lm]);
}}>
Remove
</Button>
<Button
style={{ marginLeft: 10 }}
variant="contained"
color="primary"
onClick={() => {
setExistingMutation(mut);
setNewMutationDialog(true);
}}>
Edit
</Button>
</Grid>
</Grid>
</ListItem>
);
});
}
let mutationList: JSX.Element = <List>{mutations}</List>;
return (
<React.Fragment>
<Button
variant="contained"
color="primary"
onClick={() => {
setExistingMutation(undefined);
setNewMutationDialog(true);
}}>
Add New Mutation
</Button>
<List>{mutationList}</List>
</React.Fragment>
);
};
const actions = () => {
return (
<Grid container justifyContent="space-between" direction="row">
<Grid size={{ xs: 5 }}>
{canRemove() && <DeleteButton onClick={confirmRemoveDevice}>Delete</DeleteButton>}
</Grid>
<Grid size={{ xs: 7 }} container justifyContent="flex-end">
<Button onClick={close} color="primary">
Cancel
</Button>
<Button onClick={submitSettings} color="primary" disabled={!isFormValid() || !canEdit}>
Submit
</Button>
</Grid>
</Grid>
);
};
return (
<React.Fragment>
<ResponsiveDialog
maxWidth="sm"
fullWidth
open={isDialogOpen}
onClose={close}
aria-labelledby="device-config-title">
<DialogTitle id="device-config-title">{title()}</DialogTitle>
<DialogContent className={classes.dialogContent}>
{components ? (
<React.Fragment>
<Tabs
value={currentTab}
onChange={(_, value) => setCurrentTab(value)}
indicatorColor="primary"
textColor="primary"
variant="fullWidth"
aria-label="device tabs"
classes={{ root: classes.tabSmall }}>
<Tab label={"General"} className={classes.tabSmall} />
{user.hasFeature("beta") && (
<Tab label={"Mutations"} className={classes.tabSmall} />
)}
</Tabs>
<TabPanelMine value={currentTab} index={0}>
{content()}
</TabPanelMine>
<TabPanelMine value={currentTab} index={1}>
{mutationsContent()}
</TabPanelMine>
</React.Fragment>
) : (
content()
)}
</DialogContent>
<DialogActions>{actions()}</DialogActions>
</ResponsiveDialog>
<Dialog
open={isRemoveDeviceDialogOpen}
onClose={closeConfirmRemoveDeviceDialog}
aria-labelledby="confirm-remove-device-label"
aria-describedby="confirm-remove-device-description"
className={classes.removeDeviceDialog}>
<DialogTitle id="confirm-remove-device-title">Delete {deviceForm.name()}?</DialogTitle>
<DialogContent>
<Typography variant="subtitle1" color="error" align="left">
WARNING:
</Typography>
<Typography variant="subtitle1" color="textSecondary" align="left">
Clicking 'Accept' will remove the device from all users and groups associated with it.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={closeConfirmRemoveDeviceDialog} color="primary">
Cancel
</Button>
<Button onClick={removeDevice} color="primary">
Accept
</Button>
</DialogActions>
</Dialog>
<LinearMutationBuilder
existingMutation={existingMutation}
componentsByDevice={componentsByDevice}
open={newMutationDialog}
onClose={() => {
setNewMutationDialog(false);
}}
onSubmit={newMutation => {
let lm = linearMutations;
lm.push(newMutation);
setLinearMutations(lm);
}}
/>
</React.Fragment>
);
}

215
src/device/DeviceTags.tsx Normal file
View file

@ -0,0 +1,215 @@
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, useSnackbar, useTagAPI } from "providers";
import React, { useEffect, useState } from "react";
import { pond } from "protobuf-ts/pond";
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 tagAPI = useTagAPI();
const { device, deviceTags, addTagToDevice } = props;
const [open, setOpen] = useState(false);
const [createNewOpen, setCreateNewOpen] = useState(false);
const [searchValue, setSearchValue] = useState("");
const [tags, setTags] = useState<Tag[]>([])
const loadTags = () => {
tagAPI.listTags().then(resp => {
let newTags: Tag[] = [];
resp.data.tags.forEach((tag: pond.Tag) => {
newTags.push(Tag.create(tag))
})
setTags(newTags)
})
}
useEffect(() => {
loadTags()
}, [])
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) => (
<ListItem key={tag.settings.key} divider={index === array.length - 1}>
<TagUI tag={tag} onClick={() => addTagToDevice(tag)} />
</ListItem>
));
return (
<React.Fragment>
<IconButton aria-label="add tag" onClick={() => setOpen(true)} size="small">
<AddIconCircle className={classes.addIcon} />
</IconButton>
<Dialog open={open} onClose={() => setOpen(false)} scroll="paper">
<DialogTitle>
Select tags to add
<Typography variant="body2" color="textSecondary">
{device.name()}
</Typography>
</DialogTitle>
<DialogContent>
<SearchBar
value={searchValue}
onChange={(newSearchValue: string) => setSearchValue(newSearchValue)}
/>
<List>
{tagItems.length > 0 ? (
<React.Fragment>{tagItems}</React.Fragment>
) : (
<ListItem divider>
<ListItemText secondary={"No tags found"} />
</ListItem>
)}
<ListItem onClick={() => setCreateNewOpen(true)}>
<ListItemIcon>
<AddIconCircle className={classes.addIcon} />
</ListItemIcon>
<ListItemText>Create tag</ListItemText>
</ListItem>
</List>
</DialogContent>
</Dialog>
<TagSettings open={createNewOpen} mode="add" onClose={() => setCreateNewOpen(false)} />
</React.Fragment>
);
}
interface DeviceTagProps {
tag: Tag;
removeTagFromDevice: (tag: Tag) => void;
}
function DeviceTag(props: DeviceTagProps) {
const { tag, removeTagFromDevice } = props;
const [open, setOpen] = useState(false);
return (
<React.Fragment>
<TagUI tag={tag} onClick={() => setOpen(true)} onDelete={() => removeTagFromDevice(tag)} />
<TagSettings open={open} onClose={() => setOpen(false)} tag={tag} mode="update" />
</React.Fragment>
);
}
interface DeviceTagsProps {
device: Device;
disableAdd?: boolean;
tags: pond.Tag[];
}
export default function DeviceTags(props: DeviceTagsProps) {
const { device, disableAdd } = props;
// const [{ tags }] = useGlobalState();
const tagAPI = useTagAPI()
const [tags, setTags] = useState<Tag[]>([])
// const [loading, setLoading] = useState(false)
const deviceAPI = useDeviceAPI();
const { error } = useSnackbar();
const [deviceTags, setDeviceTags] = useState<string[]>(device.status.tagKeys);
// const previousDeviceRef = useRef(device);
const loadTags = () => {
// setLoading(true)
tagAPI.listTags().then(resp => {
let newTags: Tag[] = [];
resp.data.tags.forEach((tag: pond.Tag) => {
newTags.push(Tag.create(tag))
})
setTags(newTags)
}).finally(() => {
// setLoading(false)
})
}
useEffect(() => {
let newTags: string[] = []
props.tags.forEach(tag => {
if (tag.settings) newTags.push(tag.settings?.key)
})
setDeviceTags(newTags)
}, [props.tags])
useEffect(() => {
loadTags()
}, [])
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 (
<React.Fragment>
{deviceTags.map(key => {
let tag = tags.find(t => t.settings.key === key);
if (!tag) {
return null;
}
return (
<Grid key={tag.settings.key}>
<DeviceTag tag={tag} removeTagFromDevice={removeTagFromDevice} />
</Grid>
);
})}
{disableAdd === undefined || disableAdd === false ? (
<Grid>
<AddDeviceTag device={device} deviceTags={deviceTags} addTagToDevice={addTag} />
</Grid>
) : null}
</React.Fragment>
);
}

View file

@ -0,0 +1,216 @@
import {
AutoAwesomeMosaic,
Battery20,
Check,
PermScanWifi,
} from "@mui/icons-material";
import { Card, Grid2, Skeleton, Theme, Typography, useTheme } from "@mui/material";
import { green } from "@mui/material/colors";
import { makeStyles } from "@mui/styles";
import classNames from "classnames";
import CircleGraphIcon from "common/CircleGraphIcon";
import { getContextKeys, getContextTypes } from "pbHelpers/Context";
import { pond } from "protobuf-ts/pond";
import { useDeviceAPI } from "providers";
import { useEffect, useState } from "react";
const useStyles = makeStyles((theme: Theme) => {
return ({
card: {
padding: theme.spacing(1),
height: theme.spacing(10),
alignContent: "center",
"&:hover": {
cursor: "pointer",
backgroundColor: "rgba(150, 150, 150, 0.15)",
// border: "1px solid rgba(255, 255, 255, 0.25)"
}
},
cardSelected: {
border: "1px solid " + green[400]
}
});
});
interface Props {
setSearch: React.Dispatch<React.SetStateAction<string>>,
}
export default function DevicesSummary(props: Props) {
const { setSearch } = props;
const deviceAPI = useDeviceAPI()
const theme = useTheme()
const classes = useStyles()
const [stats, setStats] = useState<pond.DeviceStatistics>(pond.DeviceStatistics.create())
const [loadingStats, setLoadingStats] = useState(false)
const [selected, setSelected] = useState("");
const loadDeviceStatistics = () => {
setLoadingStats(true)
deviceAPI.statistics(
getContextKeys(),
getContextTypes()
).then(resp => {
let stats = pond.DeviceStatistics.fromObject(resp.data.stats)
setStats(stats)
}).finally(() => {
setLoadingStats(false)
})
}
useEffect(() => {
loadDeviceStatistics()
}, [])
useEffect(() => {
switch (selected) {
case "total":
setSearch("");
break;
case "active":
setSearch("DEVICE_STATE_OK");
break;
case "warning":
setSearch("DEVICE_STATE_LOW_POWER");
break;
case "offline":
setSearch("DEVICE_STATE_MISSING");
break;
}
}, [selected])
const getPercent = (devices: number) => {
const ratio = devices/stats.total
return ratio*100
}
return (
<Grid2 container spacing={1} marginBlock={1} >
<Grid2 size={{xs: 6, sm: 3}}>
{loadingStats ?
<Skeleton className={classes.card} />
:
<Card
className={
classNames(
classes.card,
selected==="total" ? classes.cardSelected : undefined
)
}
onClick={() => setSelected("total")}
>
<Grid2 container spacing={1} justifyContent={"center"} alignItems={"center"} >
<Grid2>
<CircleGraphIcon
sx={{ marginRight: 1, marginLeft: 1 }}
progress={100}
color={"info"}
icon={<AutoAwesomeMosaic />}
/>
</Grid2>
<Grid2 marginRight={1}>
<Typography variant="h6" >
{stats.total} Devices
</Typography>
<Typography variant="subtitle2" color="textSecondary">
Total
</Typography>
</Grid2>
</Grid2>
</Card>
}
</Grid2>
<Grid2 size={{xs: 6, sm: 3}}>
<Card
className={
classNames(
classes.card,
selected==="active" ? classes.cardSelected : undefined
)
}
onClick={() => setSelected("active")}
>
<Grid2 container spacing={1} justifyContent={"center"} alignItems={"center"} >
<Grid2>
<CircleGraphIcon
sx={{ marginRight: 1, marginLeft: 1 }}
progress={getPercent(stats.active)}
color={"success"}
icon={<Check />}
/>
</Grid2>
<Grid2 marginRight={1}>
<Typography variant="h6" >
{stats.active} Devices
</Typography>
<Typography variant="subtitle2" color="textSecondary">
Active
</Typography>
</Grid2>
</Grid2>
</Card>
</Grid2>
<Grid2 size={{xs: 6, sm: 3}}>
<Card
className={
classNames(
classes.card,
selected==="warning" ? classes.cardSelected : undefined
)
}
onClick={() => setSelected("warning")}
>
<Grid2 container spacing={1} justifyContent={"center"} alignItems={"center"} >
<Grid2>
<CircleGraphIcon
sx={{ marginRight: 1, marginLeft: 1 }}
progress={getPercent(stats.warning)}
color="warning"
icon={<Battery20 />}
/>
</Grid2>
<Grid2 marginRight={1}>
<Typography variant="h6" >
{stats.warning} Devices
</Typography>
<Typography variant="subtitle2" color="textSecondary">
Low Power
</Typography>
</Grid2>
</Grid2>
</Card>
</Grid2>
<Grid2 size={{xs: 6, sm: 3}}>
<Card
className={
classNames(
classes.card,
selected==="offline" ? classes.cardSelected : undefined
)
}
onClick={() => setSelected("offline")}
>
<Grid2 container spacing={1} justifyContent={"center"} alignItems={"center"} >
<Grid2>
<CircleGraphIcon
sx={{ marginRight: 1, marginLeft: 1 }}
progress={100}
color={green[50]}
icon={<PermScanWifi />}
/>
</Grid2>
<Grid2 marginRight={1}>
<Typography variant="h6" >
{stats.offline} Devices
</Typography>
<Typography variant="subtitle2" color="textSecondary">
Offline
</Typography>
</Grid2>
</Grid2>
</Card>
</Grid2>
</Grid2>
)
}

View file

@ -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<typeof styles> {
version: string;
available: string;
}
interface State {}
class VersionChip extends React.Component<Props, State> {
render() {
const { version, available } = this.props;
const firmwareVersionHelper = getFirmwareVersionHelper(version, available);
return (
<Tooltip title={firmwareVersionHelper.tooltip}>
<Chip
variant="outlined"
label={firmwareVersionHelper.description}
icon={firmwareVersionHelper.icon}
/>
</Tooltip>
);
}
}
export default withStyles(styles)(VersionChip);

107
src/models/Component.ts Normal file
View file

@ -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;
}
}

42
src/models/Firmware.ts Normal file
View file

@ -0,0 +1,42 @@
import { pond } from "protobuf-ts/pond";
import { or } from "utils/types";
import { cloneDeep } from "lodash";
export function latestFirmwareVersion(
versions: Map<string, Firmware>,
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)));
}
}

View file

@ -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";

View file

@ -1,4 +1,4 @@
import { ChevronRight, People, PeopleOutline, PeopleOutlined, PeopleSharp, Person } from "@mui/icons-material";
import { ChevronRight, People, Person } from "@mui/icons-material";
import ChevronLeft from "@mui/icons-material/ChevronLeft";
import { darken, Divider, Grid2 as Grid, IconButton, lighten, List, ListItemButton, ListItemIcon, ListItemText, SwipeableDrawer, Theme, Toolbar, Tooltip, useTheme } from "@mui/material";
import classNames from "classnames";
@ -6,7 +6,6 @@ import { useWidth } from "../hooks/useWidth";
import { makeStyles } from "@mui/styles";
import BindaptIcon from "../products/Bindapt/BindaptIcon";
import { Link, useLocation } from "react-router-dom";
import { getSignatureAccentColour } from "services/whiteLabel";
const drawerWidth = 230;

View file

@ -8,16 +8,10 @@ import PageContainer from "./PageContainer";
import { useMobile } from "hooks";
import LoadingScreen from "app/LoadingScreen";
import SmartBreadcrumb from "common/SmartBreadcrumb";
import DeviceActions from "common/DeviceActions";
import DeviceActions from "device/DeviceActions";
import { pond } from "protobuf-ts/pond";
import { cloneDeep } from "lodash";
// const useStyles = makeStyles((theme: Theme) => {
// // const isMobile = useMobile()
// return ({
// });
// });
import DeviceOverview from "device/DeviceOverview";
export default function DevicePage() {
const deviceAPI = useDeviceAPI()
@ -30,6 +24,10 @@ export default function DevicePage() {
const [loading, setLoading] = useState(false)
const [permissions, setPermissions] = useState<pond.Permission[]>([])
const [preferences, setPreferences] = useState<pond.DevicePreferences>(pond.DevicePreferences.create())
const [tags, setTags] = useState<pond.Tag[]>([]);
const [cellularUsage, _setCellularUsage] = useState<number>(0);
const [cellularStatus, _setCellularStatus] = useState<string>("");
const loadDevice = () => {
if (loading) return
@ -46,9 +44,19 @@ export default function DevicePage() {
setLoading(true)
deviceAPI.getPageData(deviceID, getContextKeys(), getContextTypes()).then(resp => {
setDevice(Device.any(resp.data.device))
setPermissions(resp.data.permissions)
let newPermissions: pond.Permission[] = []
resp.data.permissions.forEach(perm => {
if (typeof(perm) === "string" ) {
const permNumber = pond.Permission[perm as keyof typeof pond.Permission]
newPermissions.push(permNumber)
} else {
newPermissions.push(perm)
}
})
setPermissions(newPermissions)
let u = User.any(resp.data.user);
setPreferences(u.preferences)
setTags(resp.data.tags)
}).catch(err => {
setDevice(Device.create());
// setComponents(new Map());
@ -96,11 +104,16 @@ export default function DevicePage() {
<LoadingScreen message="Loading device"/>
)
const getUsage = () => {
let usage = undefined;
if (cellularStatus && cellularStatus !== "") {
usage = { status: cellularStatus, bytes: cellularUsage };
}
return usage;
};
return (
<PageContainer padding={isMobile ? 0 : 2}>
{/* <Typography>
{device.name()}
</Typography> */}
<Grid2 container justifyContent={"space-between"}>
<Grid2>
<SmartBreadcrumb />
@ -117,6 +130,14 @@ export default function DevicePage() {
/>
</Grid2>
</Grid2>
<DeviceOverview
device={device}
// components={[...components.values()]}
components={[]}
usage={getUsage()}
loading={loading}
tags={tags}
/>
</PageContainer>
);
}

View file

@ -1,5 +1,5 @@
import { DeveloperBoard as ProvisionIcon, LibraryAdd as CreateGroupIcon } from "@mui/icons-material";
import { Box, Chip, CircularProgress, IconButton, Tab, Tabs, Theme, Tooltip, Typography } from "@mui/material";
import { DeveloperBoard as ProvisionIcon, LibraryAdd as CreateGroupIcon, CheckCircle } from "@mui/icons-material";
import { Box, Card, Chip, CircularProgress, Grid2, IconButton, Tab, Tabs, Theme, Tooltip, Typography } from "@mui/material";
import { blue, green } from "@mui/material/colors";
import { makeStyles } from "@mui/styles";
import ResponsiveTable, { Column } from "common/ResponsiveTable";
@ -16,6 +16,8 @@ import { Device, Group } from "models";
import GroupSettings from "group/GroupSettings";
import SmartBreadcrumb from "common/SmartBreadcrumb";
import GroupActions from "group/GroupActions";
import CircleGraphIcon from "common/CircleGraphIcon";
import DevicesSummary from "device/DevicesSummary";
const useStyles = makeStyles((theme: Theme) => {
return ({
@ -74,6 +76,8 @@ export default function Devices() {
const [total, setTotal] = useState(0);
const [devices, setDevices] = useState<Device[]>([])
const [isProvisionDialogOpen, setIsProvisionDialogOpen] = useState<boolean>(false);
const [stats, setStats] = useState<pond.DeviceStatistics>(pond.DeviceStatistics.create())
const [loadingStats, setLoadingStats] = useState(false)
const [groupsLoading, setGroupsLoading] = useState(false)
const [groups, setGroups] = useState<Group[]>([]);
@ -125,6 +129,8 @@ export default function Devices() {
let keys = getContextKeys()
keys.splice(keys.length - 1, 0, tab);
return keys
} else {
return getContextKeys()
}
}
@ -133,6 +139,8 @@ export default function Devices() {
let types = getContextTypes()
types.splice(types.length - 1, 0, "group");
return types
} else {
return getContextTypes()
}
}
@ -186,10 +194,28 @@ export default function Devices() {
})
}
const loadDeviceStatistics = () => {
setLoadingStats(true)
deviceAPI.statistics(
getContextKeys(),
getContextTypes()
).then(resp => {
let stats = pond.DeviceStatistics.fromObject(resp.data.stats)
// console.log(stats)
setStats(stats)
}).finally(() => {
setLoadingStats(false)
})
}
useEffect(() => {
loadDevices()
}, [limit, page, order, orderBy, search, tab])
useEffect(() => {
loadDeviceStatistics()
}, [])
useEffect(() => {
// console.log("groups loaded")
loadGroups()
@ -309,78 +335,9 @@ export default function Devices() {
return group
}
// interface MyTabButtonProps {
// onClick?: React.MouseEventHandler<HTMLButtonElement>;
// disabled?: boolean;
// }
// const [leftButtonProps, setLeftButtonProps] = useState<MyTabButtonProps>({disabled: true})
// const [rightButtonProps, setRightButtonProps] = useState<MyTabButtonProps>({disabled: true})
// Use refs to store the props received during render
// const leftButtonRef = useRef<MyTabButtonProps>({});
// const rightButtonRef = useRef<MyTabButtonProps>({});
// const [tabsHovered, setTabsHovered] = useState(false)
// const location = useLocation()
// Update state using useEffect based on ref changes (the props received)
// useEffect(() => {
// if (leftButtonRef.current.disabled !== leftButtonProps.disabled) {
// setLeftButtonProps({ disabled: leftButtonRef.current.disabled, onClick: leftButtonRef.current.onClick });
// }
// if (rightButtonRef.current.disabled !== rightButtonProps.disabled) {
// setRightButtonProps({ disabled: rightButtonRef.current.disabled, onClick: rightButtonRef.current.onClick });
// }
// }, [leftButtonRef.current, rightButtonRef.current]);
// const handleScrollButtonProps = (props: TabScrollButtonProps) => {
// if ((props.direction === "left" &&
// leftButtonProps.disabled !== props.disabled)
// ) {
// let newProps: any = {}
// newProps.disabled = props.disabled
// newProps.onClick = props.onClick
// setLeftButtonProps(newProps)
// // leftButtonRef.current = newProps
// }
// if (props.direction === "right" &&
// rightButtonProps.disabled !== props.disabled
// ) {
// let newProps: any = {}
// newProps.disabled = props.disabled
// newProps.onClick = props.onClick
// setRightButtonProps(newProps)
// // rightButtonRef.current = newProps
// }
// }
// useEffect(() => {
// if (tab !== "all") {
// const group = groups[typeof(tab) === "number" ? tab-1 : parseInt(tab)-1]
// const url = location.pathname;
// if (group) {
// const updatedUrl = url.replace(
// /(\/groups\/[^/]+)?\/devices$/, // Matches "/groups/some-id/devices" or just "/devices"
// `/groups/${group.id()}/devices`
// );
// navigate(updatedUrl, { replace: true });
// loadDevices()
// }
// } else {
// const url = location.pathname;
// const updatedUrl = url.replace(
// /(\/groups\/[^/]+)?\/devices$/, // Matches "/groups/some-id/devices" or just "/devices"
// `/devices`
// );
// navigate(updatedUrl, { replace: true });
// loadDevices()
// }
// }, [tab])
return(
<PageContainer padding={isMobile ? 0 : 2}>
{/* <SmartBreadcrumb /> */}
<Box
// onMouseEnter={() => setTabsHovered(true)}
// onMouseLeave={() => setTabsHovered(false)}
sx={{
borderRadius: 1,
border: "1px solid rgba(150, 150, 150, 0.2)",
@ -406,6 +363,7 @@ export default function Devices() {
<Tab wrapped value={"never"} label={<CreateGroupIcon className={classes.green} />} onClick={() => openGroupSettings(undefined, "add")} />
</Tabs>
</Box>
<DevicesSummary setSearch={setSearch} />
<ResponsiveTable<Device>
title={<SmartBreadcrumb prependPaths={getGroup() && ["groups", getGroup().id().toString()]} groupName={getGroup() && getGroup().name()} paddingBottom={1}/>}
subtitle={subtitle()}

View file

@ -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 <SignalCellularOff style={{ color: colour }} />;
}
return <SignalCellular4Bar style={{ color: colour }} />;
}
if (activity === Activity.missing) {
return <SignalWifiOff style={{ color: colour }} />;
}
return <SignalWifi4Bar style={{ color: colour }} />;
}
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)
};
}

View file

@ -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 = <FirmwareIcon />;
const oldFirmwareIcon = <FirmwareIcon style={{ color: oldFirmwareColour }} />;
const currentFirmwareIcon = <FirmwareIcon style={{ color: currentFirmwareColour }} />;
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;
}

67
src/pbHelpers/Status.tsx Normal file
View file

@ -0,0 +1,67 @@
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

@ -1,7 +1,6 @@
import BindaptDarkIcon from "../../assets/products/bindapt/bindaptPlusDark.png";
import BindaptLightIcon from "../../assets/products/bindapt/bindaptPlusLight.png";
import { ImgIcon } from "../../common/ImgIcon";
import React from "react";
import { useThemeType } from "../../hooks/useThemeType";
interface Props {

View file

@ -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<string, Firmware>;
firmware: Map<string, Firmware>;
// newStructure: boolean;
showErrors: boolean;
userTeamPermissions: pond.Permission[];

View file

@ -15,7 +15,7 @@ export {
// useDeviceWebsocket,
// useFieldAPI,
// useFieldMarkerAPI,
// useFirmwareAPI,
useFirmwareAPI,
useGroupAPI,
// useHarvestPlanAPI,
// useHarvestYearAPI,
@ -26,7 +26,7 @@ export {
usePermissionAPI,
// usePreferenceAPI,
// useSiteAPI,
// useTagAPI,
useTagAPI,
useTeamAPI,
useImagekitAPI,
// useTaskAPI,

View file

@ -100,6 +100,10 @@ export interface IDeviceAPIContext {
keys?: string[],
types?: string[]
) => Promise<any>;
statistics: (
keys?: string[],
types?: string[]
) => Promise<any>;
tag: (id: number, tag: string) => Promise<any>;
untag: (id: number, tag: string) => Promise<any>;
getDatacap: (id: number) => Promise<any>;
@ -177,6 +181,22 @@ export default function DeviceProvider(props: PropsWithChildren<Props>) {
return get(url);
};
const statistics = (
keys?: string[],
types?: string[]
) => {
if (types && types.length > 0 && types[0] === "team")
return get(
pondURL(
"/deviceStatistics/" +
(keys ? "?keys=" + keys.toString() : "") +
(types ? "&types=" + types.toString() : "")
)
);
const url = pondURL("/deviceStatistics");
return get<pond.GetDeviceStatisticsResponse>(url);
};
const getDevicePageData = (
id: number | string,
keys?: string[],
@ -615,6 +635,7 @@ export default function DeviceProvider(props: PropsWithChildren<Props>) {
update,
remove,
get: getDevice,
statistics,
getPageData: getDevicePageData,
getMulti,
getGeoJson: getDeviceGeoJSON,

View file

@ -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<any>;
cancelUpgrade: (deviceID: number | Long | string) => Promise<any>;
getLatestFirmware: (
platform: pond.DevicePlatform,
channel: pond.UpgradeChannel,
demo?: boolean
) => Promise<any>;
getAllLatestFirmware: () => Promise<any>;
listFirmware: (
limit: number,
offset: number,
order?: "asc" | "desc",
orderBy?: string,
search?: string,
settings?: string,
status?: string
) => Promise<any>;
removeFirmware: (platform: pond.DevicePlatform, version: string) => Promise<any>;
downloadInstaller: (platform: pond.DevicePlatform, version: string) => Promise<any>;
uploadFirmware: (
version: string,
channel: pond.UpgradeChannel,
platform: pond.DevicePlatform,
file: Blob,
breaksStorage: boolean
) => Promise<any>;
updateChannel: (
platform: pond.DevicePlatform,
version: string,
channel: pond.UpgradeChannel
) => Promise<any>;
upgradeFirmware: (device: number | string) => Promise<any>;
}
export const FirmwareAPIContext = createContext<IFirmwareAPIContext>({} 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<Props>) {
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 (
<FirmwareAPIContext.Provider
value={{
getFirmwareStatus,
cancelUpgrade,
getLatestFirmware,
getAllLatestFirmware,
listFirmware,
removeFirmware,
downloadInstaller,
uploadFirmware,
updateChannel,
upgradeFirmware
}}>
{children}
</FirmwareAPIContext.Provider>
);
}
export const useFirmwareAPI = () => useContext(FirmwareAPIContext);

View file

@ -11,6 +11,8 @@ import DeviceProvider, { useDeviceAPI } from "./deviceAPI";
import BackpackProvider, { useBackpackAPI } from "./backpackAPI";
import GroupProvider, { useGroupAPI } from "./groupAPI";
import NotificationProvider, { useNotificationAPI } from "./notificationAPI";
import TagProvider, { useTagAPI } from "./tagAPI";
import FirmwareProvider, { useFirmwareAPI } from "./firmwareAPI";
// import NoteProvider from "providers/noteAPI";
export const pondURL = (partial: string, demo: boolean = false): string => {
@ -38,7 +40,11 @@ export default function PondProvider(props: PropsWithChildren<any>) {
<GroupProvider>
<BackpackProvider>
<NotificationProvider>
{children}
<TagProvider>
<FirmwareProvider>
{children}
</FirmwareProvider>
</TagProvider>
</NotificationProvider>
</BackpackProvider>
</GroupProvider>
@ -55,7 +61,9 @@ export default function PondProvider(props: PropsWithChildren<any>) {
export {
useBackpackAPI,
useDeviceAPI,
useFirmwareAPI,
useUserAPI,
useTagAPI,
useTeamAPI,
useImagekitAPI,
usePermissionAPI,

View file

@ -0,0 +1,96 @@
import { useHTTP } from "hooks";
import { pond } from "protobuf-ts/pond";
import { createContext, PropsWithChildren, useContext } from "react";
import { pondURL } from "./pond";
import { useGlobalState } from "providers/StateContainer";
export interface ITagAPIContext {
addTag: (tag: pond.TagSettings) => Promise<any>;
updateTag: (tagID: string, tag: pond.TagSettings) => Promise<any>;
removeTag: (tagID: string) => Promise<any>;
getTag: (tagID: string) => Promise<any>;
listTags: (
limit?: number,
offset?: number,
order?: "asc" | "desc",
orderBy?: string,
search?: string,
asRoot?: boolean,
keys?: string[],
types?: string[]
) => Promise<any>;
}
export const TagAPIContext = createContext<ITagAPIContext>({} as ITagAPIContext);
interface Props {}
export default function TagProvider(props: PropsWithChildren<Props>) {
const { children } = props;
const { get, del, put, post } = useHTTP();
const [{ as }] = useGlobalState()
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"));
// };
const listTags = (
limit?: number,
offset?: number,
order?: "asc" | "desc",
orderBy?: string,
search?: string,
asRoot?: boolean,
keys?: string[],
types?: string[]
) => {
limit = limit ? limit : 100
const url = pondURL(
"/tags" +
"?limit=" +
limit +
"&offset=" +
offset +
("&order=" + (order ? order : "asc")) +
("&by=" + (orderBy ? orderBy : "deviceId")) +
(search ? "&search=" + search : "") +
(asRoot ? "&asRoot=" + asRoot.toString() : "") +
(as && !(types && types.length > 0 && types[0] === "team") ? "&as=" + as : "") +
(keys ? "&keys=" + keys.toString() : "") +
(types ? "&types=" + types.toString() : "")
);
return get<pond.ListDevicesResponse>(url);
};
return (
<TagAPIContext.Provider
value={{
addTag,
updateTag,
removeTag,
getTag,
listTags
}}>
{children}
</TagAPIContext.Provider>
);
}
export const useTagAPI = () => useContext(TagAPIContext);

617
src/teams/ObjectTeams.tsx Normal file
View file

@ -0,0 +1,617 @@
import {
Avatar,
Button,
Checkbox,
CircularProgress,
DialogActions,
DialogContent,
DialogTitle,
Divider,
FormControl,
FormControlLabel,
FormGroup,
FormLabel,
Grid2 as Grid,
IconButton,
Link,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
PaletteColor,
Theme,
Tooltip,
Typography,
useTheme
} from "@mui/material";
import ResponsiveDialog from "common/ResponsiveDialog";
import { usePermissionAPI, usePrevious, useSnackbar } from "hooks";
import { cloneDeep, isEqual } from "lodash";
import { Scope, Team, User } from "models";
import { userRoleFromPermissions } from "pbHelpers/User";
import { pond } from "protobuf-ts/pond";
import React, { useCallback, useEffect, useState } from "react";
import { or } from "utils/types";
import RemoveSelfFromObject from "user/RemoveSelfFromObject";
import { useGlobalState, useTeamAPI } from "providers";
import ShareWithTeam from "./ShareWithTeam";
import { blue, red } from "@mui/material/colors";
import { useNavigate } from "react-router-dom";
import { makeStyles } from "@mui/styles";
import { Share, RemoveCircle as RemoveUserIcon } from "@mui/icons-material";
const useStyles = makeStyles((theme: Theme) => {
const avatarBG = theme.palette.secondary["700" as keyof PaletteColor];
return ({
dialogContent: {
padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`,
[theme.breakpoints.up("sm")]: {
padding: `${theme.spacing(1)}px ${theme.spacing(3)}px`
}
},
userAvatar: {
color: "#fff",
backgroundColor: avatarBG
},
userPermissions: {
paddingLeft: theme.spacing(4),
paddingBottom: theme.spacing(1)
},
textOverflowEllipsis: {
whiteSpace: "nowrap",
overflow: "hidden",
textOverflow: "ellipsis"
},
removeIcon: {
color: red["500"],
"&:hover": {
color: red["600"]
}
},
verticalSpacing: {
paddingTop: theme.spacing(4),
paddingBottom: theme.spacing(3)
},
lessSpacing: {
paddingTop: theme.spacing(1),
paddingBottom: theme.spacing(1)
},
iconButton: {
margin: theme.spacing(1)
},
shareIcon: {
color: blue["600"],
"&:hover": {
color: blue["700"]
}
}
});
});
interface Props {
scope: Scope;
label: string;
permissions: pond.Permission[];
isDialogOpen: boolean;
closeDialogCallback: Function;
refreshCallback: Function;
userCallback?: (users?: User[] | Team[] | undefined) => void;
dialog?: string;
cardMode?: boolean;
}
export default function ObjectTeams(props: Props) {
const navigate = useNavigate();
const classes = useStyles();
const theme = useTheme();
const permissionAPI = usePermissionAPI();
const [{ user, as }] = useGlobalState();
const canProvision = user.allowedTo("provision");
const teamAPI = useTeamAPI();
const { error, success, warning } = useSnackbar();
const {
scope,
label,
permissions,
isDialogOpen,
closeDialogCallback,
refreshCallback,
userCallback,
dialog,
cardMode
} = props;
const prevPermissions = usePrevious(permissions);
const prevIsDialogOpen = usePrevious(isDialogOpen);
const [initialUsers, setInitialUsers] = useState<Team[]>([]);
const [users, setUsers] = useState<Team[]>([]);
const [canManageUsers, setCanManageUsers] = useState<boolean>(
permissions.includes(pond.Permission.PERMISSION_USERS)
);
const [canShare, setCanShare] = useState<boolean>(
permissions.includes(pond.Permission.PERMISSION_SHARE)
);
const [removedUsers, setRemovedUsers] = useState<string[]>([]);
const [removeSelfDialogIsOpen, setRemoveSelfDialogIsOpen] = useState<boolean>(false);
const [isLoading, setIsLoading] = useState<boolean>(false);
const [isShareObjectDialogOpen, setIsShareObjectDialogOpen] = useState<boolean>(false);
const setDefaultState = () => {
setInitialUsers([]);
setUsers([]);
setRemovedUsers([]);
};
const load = useCallback(() => {
setIsLoading(true);
teamAPI
.listObjectTeams(scope, as)
.then((response: any) => {
let rTeams: Team[] = [];
or(response.data, { teams: [] }).teams.forEach((user: any) => {
rTeams.push(Team.any(user));
});
rTeams = rTeams.filter(u => u.permissions.length > 0);
setInitialUsers(cloneDeep(rTeams));
setUsers(rTeams);
})
.catch((_err: any) => {
setInitialUsers([]);
setUsers([]);
})
.finally(() => setIsLoading(false));
}, [scope, teamAPI, as]);
useEffect(() => {
if (!prevIsDialogOpen && isDialogOpen) {
load();
}
if (prevPermissions !== permissions) {
setCanManageUsers(permissions.includes(pond.Permission.PERMISSION_USERS));
setCanShare(permissions.includes(pond.Permission.PERMISSION_SHARE));
}
}, [isDialogOpen, load, permissions, prevIsDialogOpen, prevPermissions]);
const closeRemoveSelfDialog = () => {
setRemoveSelfDialogIsOpen(false);
};
const close = () => {
closeRemoveSelfDialog();
closeDialogCallback();
setDefaultState();
};
const submit = () => {
permissionAPI
.updatePermissions(scope, users)
.then((_response: any) => {
success("Users were sucessfully updated for " + label);
close();
refreshCallback();
userCallback && userCallback(users);
if (cardMode) {
load();
}
})
.catch((err: any) => {
err.response.data.error
? warning(err.response.data.error)
: error("Error occured when updating users for " + label);
close();
});
};
const changeUserPermissions = (user: pond.ITeam) => (event: any) => {
let updatedUsers = cloneDeep(users);
let permissionMapping = new Map<string, pond.Permission>([
["2", pond.Permission.PERMISSION_READ],
["3", pond.Permission.PERMISSION_WRITE],
["4", pond.Permission.PERMISSION_SHARE],
["1", pond.Permission.PERMISSION_USERS],
["6", pond.Permission.PERMISSION_FILE_MANAGEMENT]
]);
let permission = or(
permissionMapping.get(event.target.value),
pond.Permission.PERMISSION_INVALID
);
for (let i = 0; i < updatedUsers.length; i++) {
let currUser = updatedUsers[i];
if (
user &&
user.settings &&
currUser &&
currUser.settings &&
currUser.settings.key === user.settings.key
) {
let permissions = user.permissions ? cloneDeep(user.permissions) : [];
if (permissions.includes(permission)) {
permissions = permissions.filter(
(curPermission: pond.Permission) => curPermission !== permission
);
} else {
permissions.push(permission);
}
updatedUsers[i].permissions = permissions;
break;
}
}
setUsers(updatedUsers);
};
const removeUser = (user: string) => {
let updatedUsers = cloneDeep(users);
let updatedRemovedUsers = cloneDeep(removedUsers);
for (let i = 0; i < updatedUsers.length; i++) {
let currUser = updatedUsers[i];
if (currUser && currUser.settings && currUser.settings.key === user) {
updatedRemovedUsers.push(currUser.settings.key);
updatedUsers[i].permissions = [];
break;
}
}
setUsers(updatedUsers);
setRemovedUsers(updatedRemovedUsers);
};
const checkIsSelf = (checkUser: pond.ITeam): boolean => {
const id = checkUser && checkUser.settings ? checkUser.settings.key : "";
return user.id() === id;
};
const userIsRemoved = (user: pond.ITeam): boolean => {
const id = user && user.settings ? user.settings.key : "";
return removedUsers.includes(or(id, ""));
};
const objectUsersUnchanged = (): boolean => {
return isEqual(initialUsers, users);
};
const openRemoveSelfDialog = () => {
setRemoveSelfDialogIsOpen(true);
};
const openShareObjectDialog = () => {
setIsShareObjectDialogOpen(true);
};
const closeShareObjectDialog = (shared: boolean | undefined) => {
setIsShareObjectDialogOpen(false);
if (shared) {
load();
}
};
// ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ UI BEGINS ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
const title = () => {
return (
<Grid container direction="column" justifyContent="space-between">
<Grid container direction="row" justifyContent="space-between">
<Grid size={{ xs: 8 }}>
Teams
<Typography variant="body2" color="textSecondary">
{label}
</Typography>
</Grid>
<Grid size={{ xs: 4 }} container justifyContent="flex-end">
{canShare && (
<Tooltip title={"Share " + label}>
<IconButton
aria-label="Share"
className={classes.iconButton}
onClick={openShareObjectDialog}>
<Share className={classes.shareIcon} />
</IconButton>
</Tooltip>
)}
</Grid>
</Grid>
{dialog && (
<Typography variant="body2" color="textSecondary" style={{ marginTop: "4px" }}>
{dialog}
</Typography>
)}
</Grid>
);
};
const userPermissionManagement = (user: Team) => {
let permissions = user.permissions;
if (
!canManageUsers ||
userIsRemoved(user) ||
//check they have all permissions
(permissions.includes(pond.Permission.PERMISSION_USERS) &&
permissions.includes(pond.Permission.PERMISSION_READ) &&
permissions.includes(pond.Permission.PERMISSION_WRITE) &&
permissions.includes(pond.Permission.PERMISSION_SHARE) &&
permissions.includes(pond.Permission.PERMISSION_FILE_MANAGEMENT))
) {
return null;
}
const canRead = permissions.includes(pond.Permission.PERMISSION_READ);
return (
<FormControl component="fieldset" fullWidth className={classes.userPermissions}>
<FormLabel component="legend"></FormLabel>
<FormGroup>
<Grid container direction="row" justifyContent="flex-start">
<Grid size={{ xs: 6, sm: 3 }} container justifyContent="center">
<FormControlLabel
control={
<Checkbox
checked={permissions.includes(pond.Permission.PERMISSION_READ)}
onChange={changeUserPermissions(user)}
value={pond.Permission.PERMISSION_READ as pond.Permission}
/>
}
label="View"
labelPlacement="end"
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }} container justifyContent="center">
<FormControlLabel
control={
<Checkbox
disabled={!canRead}
checked={permissions.includes(pond.Permission.PERMISSION_WRITE)}
onChange={changeUserPermissions(user)}
value={pond.Permission.PERMISSION_WRITE as pond.Permission}
/>
}
label="Edit"
labelPlacement="end"
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }} container justifyContent="center">
<FormControlLabel
control={
<Checkbox
disabled={!canRead}
checked={permissions.includes(pond.Permission.PERMISSION_SHARE)}
onChange={changeUserPermissions(user)}
value={pond.Permission.PERMISSION_SHARE as pond.Permission}
/>
}
label="Share"
labelPlacement="end"
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }} container justifyContent="center">
<FormControlLabel
control={
<Checkbox
disabled={!canRead}
checked={permissions.includes(pond.Permission.PERMISSION_USERS)}
onChange={changeUserPermissions(user)}
value={pond.Permission.PERMISSION_USERS as pond.Permission}
/>
}
label="Manage Users"
labelPlacement="end"
/>
</Grid>
<Grid size={{ xs: 6, sm: 3 }} container justifyContent="center">
<FormControlLabel
control={
<Checkbox
disabled={!canRead}
checked={permissions.includes(pond.Permission.PERMISSION_FILE_MANAGEMENT)}
onChange={changeUserPermissions(user)}
value={pond.Permission.PERMISSION_FILE_MANAGEMENT as pond.Permission}
/>
}
label="File Management"
labelPlacement="end"
/>
</Grid>
</Grid>
</FormGroup>
</FormControl>
);
};
const goToTeam = (teamKey: string) => {
navigate("/teams/" + teamKey);
};
const userListItem = (user: Team) => {
let isSelf = checkIsSelf(user);
let isRemoved = userIsRemoved(user);
let name = user.name();
let permissions = user.permissions;
let userSettings = pond.TeamSettings.create(user.settings !== null ? user.settings : undefined);
return (
<ListItem
alignItems="flex-start"
sx={{
opacity: isRemoved ? 0.5 : 1,
pointerEvents: isRemoved ? 'none' : 'auto',
cursor: isRemoved ? 'not-allowed' : 'pointer',
}}
>
<ListItemAvatar>
<Avatar
alt={name}
src={
userSettings.avatar && userSettings.avatar !== "" ? userSettings.avatar : undefined
}
className={classes.userAvatar}>
{!(userSettings.avatar && userSettings.avatar !== "") && name}
</Avatar>
</ListItemAvatar>
<ListItemText
className={classes.textOverflowEllipsis}
disableTypography
primary={
<Link
onClick={() => {
goToTeam(user.key());
}}>
<Typography
component="div"
variant="body1"
color="textPrimary"
style={{ cursor: "pointer" }}>
{name + (isSelf ? " (you)" : "")}
</Typography>
</Link>
}
secondary={
<React.Fragment>
<Typography component="div" variant="caption" color="textSecondary">
{isRemoved ? "REMOVED " : userRoleFromPermissions(permissions)}
</Typography>
</React.Fragment>
}
/>
{isSelf ? (
<ListItemSecondaryAction>
<Tooltip title={"Remove yourself from " + label}>
<IconButton
edge="end"
aria-label={"Remove yourself from " + label}
onClick={openRemoveSelfDialog}>
<RemoveUserIcon className={classes.removeIcon} />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
) : (
canManageUsers &&
!isRemoved &&
(!permissions.includes(pond.Permission.PERMISSION_USERS) || canProvision) && (
<ListItemSecondaryAction>
<Tooltip title="Revoke user's access">
<IconButton
edge="end"
aria-label="Remove user from device"
onClick={() => removeUser(or(user.settings, { key: "" }).key)}>
<RemoveUserIcon className={classes.removeIcon} />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
)
)}
</ListItem>
);
};
const objectUsersList = () => {
//const sortedUsers = sortUsersByRole(users);
let userListItems: any = [];
for (var i = 0; i < users.length; i++) {
let user = users[i];
if (user) {
userListItems.push(
<React.Fragment key={i}>
{userListItem(user)}
{userPermissionManagement(user)}
<Divider />
</React.Fragment>
);
}
}
return <List>{userListItems}</List>;
};
const content = () => {
if (isLoading) {
return (
<Grid container justifyContent="center" alignContent="center" className={classes.verticalSpacing}>
<CircularProgress />
</Grid>
);
}
if (users.length < 1) {
return (
<React.Fragment>
<Typography
variant="h6"
align="center"
color="textSecondary"
className={!cardMode ? classes.verticalSpacing : ""}
style={{ padding: cardMode ? 0 : "" }}>
No teams found.
</Typography>
</React.Fragment>
);
}
return objectUsersList();
};
const actions = () => {
return (
<Grid container direction="row" justifyContent="space-between">
<Grid size={{ xs: 4 }}></Grid>
<Grid size={{ xs: 8 }} container justifyContent="flex-end">
{!cardMode && (
<Button onClick={close} color="primary">
Cancel
</Button>
)}
{canManageUsers && (
<Button onClick={submit} color="primary" disabled={objectUsersUnchanged()}>
{cardMode ? "Update" : "Submit"}
</Button>
)}
</Grid>
</Grid>
);
};
const dialogs = () => {
return (
<React.Fragment>
<RemoveSelfFromObject
scope={scope}
label={label}
isDialogOpen={removeSelfDialogIsOpen}
closeDialogCallback={closeRemoveSelfDialog}
/>
<ShareWithTeam
scope={scope}
label={label}
permissions={permissions}
isDialogOpen={isShareObjectDialogOpen}
closeDialogCallback={closeShareObjectDialog}
/>
</React.Fragment>
);
};
if (cardMode) {
return (
<React.Fragment>
<Typography variant="h6">{title()}</Typography>
<div style={{ overflowY: "scroll" }}>
<Divider />
{content()}
{actions()}
{dialogs()}
<div style={{ marginTop: theme.spacing(2) }} />
</div>
</React.Fragment>
);
}
return (
<ResponsiveDialog
fullWidth
maxWidth="xs"
open={props.isDialogOpen}
onClose={close}
aria-labelledby="object-users-dialog">
<DialogTitle id="object-users-title">{title()}</DialogTitle>
<Divider />
<DialogContent className={classes.dialogContent}>{content()}</DialogContent>
<DialogActions>{actions()}</DialogActions>
{dialogs()}
</ResponsiveDialog>
);
}

540
src/teams/ShareWithTeam.tsx Normal file
View file

@ -0,0 +1,540 @@
import {
Button,
Checkbox,
CircularProgress,
DialogActions,
DialogContent,
DialogTitle,
Divider,
FormControl,
FormControlLabel,
FormGroup,
FormLabel,
IconButton,
Link,
List,
ListItem,
ListItemSecondaryAction,
ListItemText,
Tab,
Tabs,
TextField,
Theme,
Tooltip,
Typography
} from "@mui/material";
import {
Add as AddIcon,
Link as LinkIcon,
LinkOff,
RemoveCircle as RemoveCircleIcon
} from "@mui/icons-material";
// import { MobileDateTimePicker } from "@material-ui/pickers";
import ResponsiveDialog from "common/ResponsiveDialog";
import { usePermissionAPI, usePrevious, useSnackbar } from "hooks";
import { cloneDeep } from "lodash";
import { binScope, Scope, ShareableLink } from "models";
import moment, { Moment } from "moment";
import { pond } from "protobuf-ts/pond";
import React, { useCallback, useEffect, useState } from "react";
import { openSnackbar } from "providers/Snackbar";
// import { Status } from "@sentry/react";
import { useGateAPI, useBinAPI } from "providers";
import TeamSearch from "./TeamSearch";
import { green, red } from "@mui/material/colors";
import { makeStyles } from "@mui/styles";
import { MobileDateTimePicker } from "@mui/x-date-pickers";
const useStyles = makeStyles((theme: Theme) => {
return ({
dialogContent: {
marginTop: theme.spacing(2)
},
formContainer: {
display: "flex",
flexWrap: "wrap"
},
formControl: {
margin: theme.spacing(1),
minWidth: 160,
width: "100%"
},
emailForm: {
paddingBottom: theme.spacing(2)
},
fabContainer: {
position: "relative",
minHeight: "64px"
},
addIcon: {
color: green[500],
"&:hover": {
color: green[600]
}
},
removeIcon: {
color: red[500],
"&:hover": {
color: red[600]
}
},
grey: {
color: theme.palette.text.secondary
}
});
});
interface Props {
scope: Scope;
label: string;
permissions: pond.Permission[];
isDialogOpen: boolean;
closeDialogCallback: Function;
}
export default function ShareObject(props: Props) {
const binAPI = useBinAPI();
const gateAPI = useGateAPI();
const classes = useStyles();
const permissionAPI = usePermissionAPI();
const { info, success } = useSnackbar();
const { scope, label, permissions, isDialogOpen, closeDialogCallback } = props;
const [sharedPermissions, setSharedPermissions] = useState<pond.Permission[]>([
pond.Permission.PERMISSION_READ
]);
const [tab, setTab] = useState<number>(0);
const prevTab = usePrevious(tab);
const [isLoadingLinks, setIsLoadingLinks] = useState<boolean>(false);
const [shareableLinks, setShareableLinks] = useState<ShareableLink[]>([]);
const [newLinkExpiration, setNewLinkExpiration] = useState<Moment>(moment().add(1, "week"));
const [isNewLinkInfinite, setIsNewLinkInfinite] = useState<boolean>(false);
const [teamKey, setTeamKey] = useState<string>("");
const share = () => {
permissionAPI.shareObjectByKey(scope, teamKey, sharedPermissions).then(resp => {
let shareBins = true;
if (resp && resp.data && resp.data.existing) {
success(label + " was shared with team");
} else {
success("Team was sent an email to sign up");
shareBins = false;
}
let successBins = true;
if (scope.kind === "binyard" && shareBins) {
binAPI.listBins(1000, 0, "asc", "name", scope.key).then(resp => {
resp.data.bins.forEach(bin => {
if (bin.settings) {
let newScope = binScope(bin.settings?.key);
permissionAPI.shareObjectByKey(newScope, teamKey, sharedPermissions).catch(err => {
successBins = false;
});
}
});
});
if (!successBins) {
openSnackbar("error", "One or more bins failed to share");
} else {
openSnackbar("success", "Bins shared");
}
}
let successGates = true;
if (scope.kind === "terminal" && shareBins) {
gateAPI
.listGates(1000, 0, undefined, undefined, undefined, undefined, [scope.key], [scope.kind])
.then(resp => {
resp.data.gates.forEach(gate => {
if (gate) {
let newScope = { key: gate.key, kind: "gate" } as Scope;
permissionAPI.shareObjectByKey(newScope, teamKey, sharedPermissions).catch(err => {
successGates = false;
});
}
});
});
if (!successGates) {
openSnackbar("error", "One or more Gates failed to share");
} else {
openSnackbar("success", "Gates shared");
}
}
close(true);
});
/*permissionAPI
.shareObject(scope, email, sharedPermissions)
.then((result: any) => {
let shareBins = true;
if (result && result.data && result.data.existing) {
success(label + " was shared with " + email);
} else {
success(email + " was sent an email to sign up");
shareBins = false;
}
let successBins = true;
if (scope.kind === "binyard" && shareBins) {
binAPI.listBins(1000, 0, "asc", "name", scope.key).then(resp => {
resp.data.bins.forEach(bin => {
if (bin.settings) {
let newScope = binScope(bin.settings?.key);
permissionAPI.shareObject(newScope, email, sharedPermissions).catch(err => {
successBins = false;
});
}
});
});
if (!successBins) {
openSnackbar("error", "One or more bins failed to share with " + email);
} else {
openSnackbar(Status.Success, "Bins shared with " + email);
}
}
closeAfterShare();
})
.catch((err: any) => {
error("Unable to share " + label + " with " + email);
close();
});*/
};
const loadShareableLinks = useCallback(() => {
setIsLoadingLinks(true);
permissionAPI
.listShareableLinks(scope)
.then((response: any) => {
let rawShareableLinks = response.data.links ? response.data.links : [];
let rShareableLinks = rawShareableLinks.map((rawShareableLink: any) =>
ShareableLink.any(rawShareableLink)
);
setShareableLinks(rShareableLinks);
})
.catch((error: any) => {
setShareableLinks([]);
console.log("Error occured while loading shareable links");
})
.finally(() => {
setIsLoadingLinks(false);
});
}, [permissionAPI, scope]);
useEffect(() => {
if (tab === 1 && prevTab !== tab) {
loadShareableLinks();
}
}, [loadShareableLinks, prevTab, tab]);
const getNeverExpires = () => {
return "";
};
const createShareableLink = () => {
const expiration = isNewLinkInfinite ? getNeverExpires() : newLinkExpiration.toISOString();
permissionAPI
.addShareableLink(scope, expiration)
.then((response: any) => {
loadShareableLinks();
})
.catch((error: any) => {
console.log("Error occured while creating shareable link");
});
};
const revokeShareableLink = (code: string) => {
permissionAPI
.removeShareableLink(scope, code)
.then((response: any) => {
loadShareableLinks();
})
.catch((error: any) => {
console.log("Error occured while removing the shareable link");
});
};
const getShareableLinkURL = (code: string): string => {
const base = window.location.origin;
return base + "/" + scope.kind + "s/" + code;
};
const copyShareableLinkToClipboard = (code: string) => {
const url = getShareableLinkURL(code);
navigator.clipboard.writeText(url);
info("Copied link to clipboard");
};
const close = (callback: boolean) => {
closeDialogCallback(callback);
};
const changePermissions = (event: any) => {
let updatedSharedPermissions: Array<pond.Permission> = cloneDeep(sharedPermissions);
let permission: pond.Permission;
switch (event.target.value) {
case "1":
permission = pond.Permission.PERMISSION_USERS;
break;
case "2":
permission = pond.Permission.PERMISSION_READ;
break;
case "3":
permission = pond.Permission.PERMISSION_WRITE;
break;
case "4":
permission = pond.Permission.PERMISSION_SHARE;
break;
case "6":
permission = pond.Permission.PERMISSION_FILE_MANAGEMENT;
break;
default:
permission = pond.Permission.PERMISSION_INVALID;
break;
}
if (updatedSharedPermissions.includes(permission)) {
updatedSharedPermissions = updatedSharedPermissions.filter(
(curPermission: pond.Permission) => curPermission !== permission
);
} else {
updatedSharedPermissions.push(permission);
}
setSharedPermissions(updatedSharedPermissions);
};
const changeTab = (event: any, newTab: number) => {
setTab(newTab);
};
const isValid = () => {
return teamKey !== "" && sharedPermissions.length > 0;
};
// UI BEGINS
const teamsList = () => {
return <TeamSearch label="Team" setTeamCallback={setTeamKey} />;
};
const permissionsForm = () => {
return (
<FormControl component="fieldset" fullWidth variant="outlined">
<FormLabel component="legend">Permissions</FormLabel>
<FormGroup>
{permissions.includes(pond.Permission.PERMISSION_READ) && (
<FormControlLabel
control={
<Checkbox
disabled={sharedPermissions.includes(pond.Permission.PERMISSION_READ)}
checked={sharedPermissions.includes(pond.Permission.PERMISSION_READ)}
onChange={changePermissions}
value={pond.Permission.PERMISSION_READ as pond.Permission}
/>
}
label="View"
labelPlacement="end"
/>
)}
{permissions.includes(pond.Permission.PERMISSION_WRITE) && (
<FormControlLabel
control={
<Checkbox
checked={sharedPermissions.includes(pond.Permission.PERMISSION_WRITE)}
onChange={changePermissions}
value={pond.Permission.PERMISSION_WRITE as pond.Permission}
/>
}
label="Edit"
labelPlacement="end"
/>
)}
{permissions.includes(pond.Permission.PERMISSION_SHARE) && (
<FormControlLabel
control={
<Checkbox
checked={sharedPermissions.includes(pond.Permission.PERMISSION_SHARE)}
onChange={changePermissions}
value={pond.Permission.PERMISSION_SHARE as pond.Permission}
/>
}
label="Share"
labelPlacement="end"
/>
)}
{permissions.includes(pond.Permission.PERMISSION_USERS) && (
<FormControlLabel
control={
<Checkbox
checked={sharedPermissions.includes(pond.Permission.PERMISSION_USERS)}
onChange={changePermissions}
value={pond.Permission.PERMISSION_USERS as pond.Permission}
/>
}
label="Manage Users"
labelPlacement="end"
/>
)}
{permissions.includes(pond.Permission.PERMISSION_FILE_MANAGEMENT) && (
<FormControlLabel
control={
<Checkbox
checked={sharedPermissions.includes(pond.Permission.PERMISSION_FILE_MANAGEMENT)}
onChange={changePermissions}
value={pond.Permission.PERMISSION_FILE_MANAGEMENT as pond.Permission}
/>
}
label="Files"
labelPlacement="end"
/>
)}
</FormGroup>
</FormControl>
);
};
const emailSharing = () => {
return (
<React.Fragment>
{teamsList()}
<div style={{ width: "auto", height: "1rem" }} />
{permissionsForm()}
</React.Fragment>
);
};
const shareableLinksList = () => {
const now = moment();
let items: any[] = [];
shareableLinks.forEach((link: ShareableLink) => {
const expiration = moment(link.settings.expiration);
const neverExpires = link.settings.expiration === getNeverExpires();
const expired = !neverExpires && expiration < now;
items.push(
<ListItem key={link.key()}>
{expired ? (
<IconButton disabled>
<LinkOff />
</IconButton>
) : (
<Tooltip title="Click to copy link">
<IconButton onClick={() => copyShareableLinkToClipboard(link.key())}>
<LinkIcon />
</IconButton>
</Tooltip>
)}
<ListItemText
primary={
expired ? (
<span className={classes.grey}>{"/devices/" + link.key()}</span>
) : (
<Link href={getShareableLinkURL(link.key())} target="_blank">
{"/devices/" + link.key()}
</Link>
)
}
secondary={
neverExpires ? (
"Never expires"
) : (
<Tooltip title={expiration.calendar()}>
<span>{(expired ? "Expired " : "Expires ") + expiration.fromNow()}</span>
</Tooltip>
)
}
/>
<ListItemSecondaryAction>
<Tooltip title="Revoke shareable link">
<IconButton onClick={() => revokeShareableLink(link.key())}>
<RemoveCircleIcon className={classes.removeIcon} />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
);
});
return (
<List>
{isLoadingLinks ? <CircularProgress /> : <React.Fragment>{items}</React.Fragment>}
{shareableLinks.length > 0 && <Divider variant="middle" />}
<ListItem>
<FormGroup row>
<MobileDateTimePicker
disabled={isNewLinkInfinite}
// renderInput={props => <TextField {...props} helperText="" />}
label="Expiration Date"
value={newLinkExpiration}
onChange={date => setNewLinkExpiration(date as Moment)}
disablePast
/>
<FormControlLabel
control={
<Checkbox
checked={isNewLinkInfinite}
onChange={(_, checked) => setIsNewLinkInfinite(checked)}
value="isNewLinkInfinite"
color="secondary"
/>
}
label="Never expires"
/>
</FormGroup>
<ListItemSecondaryAction>
<Tooltip title="Create new shareable link">
<IconButton
className={classes.addIcon}
color="default"
onClick={() => createShareableLink()}>
<AddIcon className={classes.addIcon} />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
</List>
);
};
const content = () => {
switch (tab) {
case 1:
return <React.Fragment>{shareableLinksList()}</React.Fragment>;
default:
return <React.Fragment>{emailSharing()}</React.Fragment>;
}
};
return (
<ResponsiveDialog
maxWidth="sm"
fullWidth
open={isDialogOpen}
onClose={() => close(false)}
aria-labelledby="share-dialog-title">
<DialogTitle id="share-dialog-title">
Share
<Typography variant="body2" color="textSecondary">
{label}
</Typography>
</DialogTitle>
{scope.kind === "device" && (
<Tabs value={tab} onChange={changeTab}>
<Tab label="Share by Email" />
<Tab label="Shareable Links" />
</Tabs>
)}
<Divider />
<DialogContent className={classes.dialogContent}>{content()}</DialogContent>
<DialogActions>
<Button onClick={() => close(false)} color="primary">
Cancel
</Button>
{tab === 0 && (
<Button onClick={share} color="primary" disabled={!isValid()}>
Share
</Button>
)}
</DialogActions>
</ResponsiveDialog>
);
}

98
src/teams/TeamSearch.tsx Normal file
View file

@ -0,0 +1,98 @@
import { Box } from "@mui/material";
import SearchSelect, { Option } from "common/SearchSelect";
import { useGlobalState, useTeamAPI, useUserAPI } from "providers";
import React, { useCallback, useEffect, useState } from "react";
interface Props {
className?: string;
setTeamCallback?: React.Dispatch<React.SetStateAction<string>>;
style?: React.CSSProperties;
label?: string;
loadUsers?: boolean;
}
export default function TeamSearch(props: Props) {
const { className, setTeamCallback, style, label, loadUsers } = props;
const teamAPI = useTeamAPI();
const userAPI = useUserAPI();
const [selectedTeam, setSelectedTeam] = useState<Option | null>(null);
const [teams, setTeams] = useState<Option[]>([]);
const [search, setSearch] = useState("");
const [loading, setLoading] = useState(false);
const [{ user }] = useGlobalState();
const onChange = (e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
setSearch(e.target.value);
};
const loadTeams = useCallback(() => {
let options: Option[] = [];
setLoading(true);
teamAPI
.listTeams(10, 0, "desc", undefined, search)
.then(resp => {
if (loadUsers)
options.push({
value: user.id(),
label: "Yourself",
icon: "default",
group: "Users"
});
resp.data.teams.forEach(team => {
let o = {
value: team.settings?.key,
label: team.settings!.name,
icon: team.settings?.avatar,
group: "Teams"
};
options.push(o);
});
})
.finally(() => {
if (loadUsers) {
userAPI
.listUsers(10, 0, "desc", undefined, search)
.then(resp => {
resp.users.forEach(user => {
options.push({
value: user.id(),
label: user.name(),
icon: user.settings.avatar,
group: "Users"
});
});
})
.finally(() => {
setLoading(false);
});
} else {
setLoading(false);
}
setTeams(options);
});
}, [teamAPI, loadUsers, userAPI, user, search]);
useEffect(() => {
loadTeams();
}, [loadTeams]);
return (
<Box className={className} style={style}>
<SearchSelect
getOptionSelected={() => {
return true;
}}
label={label ? label : "View As"}
options={teams}
loading={loading}
group
selected={selectedTeam}
onChange={onChange}
changeSelection={(option: Option | null) => {
setSelectedTeam(option);
if (setTeamCallback) setTeamCallback(option?.value);
}}
/>
</Box>
);
}