merged tag stuff
This commit is contained in:
commit
4f31d036ef
41 changed files with 5028 additions and 126 deletions
48
src/app/FirmwareLoader.tsx
Normal file
48
src/app/FirmwareLoader.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
81
src/common/CircleGraphIcon.tsx
Normal file
81
src/common/CircleGraphIcon.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
42
src/common/ColourPicker.tsx
Normal file
42
src/common/ColourPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
src/common/LinearMutationBuilder.tsx
Normal file
308
src/common/LinearMutationBuilder.tsx
Normal 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
20
src/common/StatusChip.tsx
Normal 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
62
src/common/Tag.tsx
Normal 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
231
src/common/TagSettings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
src/common/time/DateRange.ts
Normal file
60
src/common/time/DateRange.ts
Normal 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);
|
||||
}
|
||||
280
src/common/time/DateSelect.tsx
Normal file
280
src/common/time/DateSelect.tsx
Normal 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);
|
||||
79
src/common/time/PeriodSelect.tsx
Normal file
79
src/common/time/PeriodSelect.tsx
Normal 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
202
src/common/time/TimeBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/common/time/duration.ts
Normal file
53
src/common/time/duration.ts
Normal 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
27
src/common/time/locale.ts
Normal 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"
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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}
|
||||
239
src/device/DeviceOverview.tsx
Normal file
239
src/device/DeviceOverview.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
680
src/device/DeviceSettings.tsx
Normal file
680
src/device/DeviceSettings.tsx
Normal 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
215
src/device/DeviceTags.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
216
src/device/DevicesSummary.tsx
Normal file
216
src/device/DevicesSummary.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
33
src/device/VersionChip.tsx
Normal file
33
src/device/VersionChip.tsx
Normal 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
107
src/models/Component.ts
Normal 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
42
src/models/Firmware.ts
Normal 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)));
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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()}
|
||||
|
|
|
|||
100
src/pbHelpers/Connectivity.tsx
Normal file
100
src/pbHelpers/Connectivity.tsx
Normal 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)
|
||||
};
|
||||
}
|
||||
44
src/pbHelpers/FirmwareVersion.tsx
Normal file
44
src/pbHelpers/FirmwareVersion.tsx
Normal 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
67
src/pbHelpers/Status.tsx
Normal 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;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
205
src/providers/pond/firmwareAPI.tsx
Normal file
205
src/providers/pond/firmwareAPI.tsx
Normal 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);
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
96
src/providers/pond/tagAPI.tsx
Normal file
96
src/providers/pond/tagAPI.tsx
Normal 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
617
src/teams/ObjectTeams.tsx
Normal 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
540
src/teams/ShareWithTeam.tsx
Normal 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
98
src/teams/TeamSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue