frontend/src/device/DeviceSettings.tsx

680 lines
22 KiB
TypeScript

import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControl,
FormControlLabel,
Grid2 as Grid,
List,
ListItem,
MenuItem,
Switch,
Tab,
Tabs,
TextField,
Theme,
Typography
} from "@mui/material";
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 { quack } from "protobuf-ts/quack";
import LinearMutationBuilder from "common/LinearMutationBuilder";
import { makeStyles } from "@mui/styles";
import { useNavigate } from "react-router-dom";
import CancelSubmit from "common/CancelSubmit";
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, as }] = 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, as)
.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(), as)
.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" width={"100%"}>
<Grid>
{canRemove() && <DeleteButton onClick={confirmRemoveDevice}>Delete</DeleteButton>}
</Grid>
<Grid container justifyContent="flex-end">
<CancelSubmit onSubmit={submitSettings} onCancel={close}/>
{/* <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>
);
}