mirror of
https://github.com/Hypfer/Valetudo.git
synced 2025-10-26 11:27:27 +00:00
feat(ui): Loading animation cleanup
This commit is contained in:
parent
31519e164c
commit
d8714ddeb9
@ -1,18 +1,9 @@
|
||||
import {Backdrop, Button, styled, Typography,} from "@mui/material";
|
||||
import {Button} from "@mui/material";
|
||||
import {SnackbarKey, useSnackbar} from "notistack";
|
||||
import React from "react";
|
||||
import {Capability, useCapabilitiesQuery} from "./api";
|
||||
import ValetudoSplash from "./components/ValetudoSplash";
|
||||
|
||||
const StyledBackdrop = styled(Backdrop)(({theme}) => {
|
||||
return {
|
||||
zIndex: theme.zIndex.drawer + 1,
|
||||
color: "#fff",
|
||||
display: "flex",
|
||||
flexFlow: "column",
|
||||
};
|
||||
});
|
||||
|
||||
const Context = React.createContext<Capability[]>([]);
|
||||
|
||||
const CapabilitiesProvider = (props: {
|
||||
@ -71,16 +62,10 @@ const CapabilitiesProvider = (props: {
|
||||
|
||||
return (
|
||||
<Context.Provider value={capabilities ?? []}>
|
||||
<StyledBackdrop
|
||||
open={capabilitiesPending}
|
||||
style={{
|
||||
transitionDelay: capabilitiesPending ? "800ms" : "0ms",
|
||||
}}
|
||||
unmountOnExit
|
||||
>
|
||||
{
|
||||
capabilitiesPending &&
|
||||
<ValetudoSplash/>
|
||||
<Typography variant="caption">Loading capabilities...</Typography>
|
||||
</StyledBackdrop>
|
||||
}
|
||||
{
|
||||
capabilities &&
|
||||
children
|
||||
|
||||
@ -17,6 +17,7 @@ import {
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Paper,
|
||||
Skeleton,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
@ -40,7 +41,6 @@ import {
|
||||
useWifiConfigurationMutation,
|
||||
useWifiScanQuery,
|
||||
} from "./api";
|
||||
import LoadingFade from "./components/LoadingFade";
|
||||
import {LoadingButton} from "@mui/lab";
|
||||
import {useCapabilitiesSupported} from "./CapabilitiesProvider";
|
||||
|
||||
@ -219,7 +219,7 @@ const ProvisioningPage = (): React.ReactElement => {
|
||||
const robotInformationElement = React.useMemo(() => {
|
||||
if (robotInformationPending || versionPending) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
<Skeleton height={"6rem"}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,14 @@
|
||||
import React from "react";
|
||||
import {Avatar, ListItem, ListItemAvatar, ListItemText, MenuItem, Select, Typography} from "@mui/material";
|
||||
import LoadingFade from "../LoadingFade";
|
||||
import {
|
||||
Avatar,
|
||||
CircularProgress,
|
||||
ListItem,
|
||||
ListItemAvatar,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Select,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
|
||||
export type SelectListMenuItemOption = {
|
||||
value: string,
|
||||
@ -31,7 +39,7 @@ export const SelectListMenuItem: React.FunctionComponent<{
|
||||
let select;
|
||||
|
||||
if (loadingOptions) {
|
||||
select = <LoadingFade/>;
|
||||
select = <CircularProgress/>;
|
||||
} else if (loadError) {
|
||||
select = <Typography variant="body2" color="error">Error</Typography>;
|
||||
} else {
|
||||
|
||||
@ -1,4 +1,14 @@
|
||||
import {Box, CircularProgress, Grid, Icon, Paper, Slider, sliderClasses, styled, Typography,} from "@mui/material";
|
||||
import {
|
||||
Box,
|
||||
Grid,
|
||||
Icon,
|
||||
Paper,
|
||||
Skeleton,
|
||||
Slider,
|
||||
sliderClasses,
|
||||
styled,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import {Mark} from "@mui/base";
|
||||
import React from "react";
|
||||
import {
|
||||
@ -102,7 +112,7 @@ const PresetSelectionControl = (props: PresetSelectionProps): React.ReactElement
|
||||
if (presetsPending) {
|
||||
return (
|
||||
<Grid item>
|
||||
<CircularProgress size={20} />
|
||||
<Skeleton height={"3rem"} />
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
@ -22,7 +22,7 @@ export const useCommittingSlider = (initialValue: number, onChange: (value: numb
|
||||
|
||||
setResetTimeout(setTimeout(() => {
|
||||
setSliderValue(initialValue);
|
||||
}, 1000));
|
||||
}, 2000));
|
||||
}
|
||||
}, [sliderValue, initialValue, adoptedValue, getResetTimeout]);
|
||||
|
||||
|
||||
@ -4,18 +4,18 @@ import MapManagement from "./MapManagement";
|
||||
import EditMapPage from "../map/EditMapPage";
|
||||
import {useCapabilitiesSupported} from "../CapabilitiesProvider";
|
||||
import {Capability} from "../api";
|
||||
import MQTTConnectivity from "./connectivity/MQTTConnectivity";
|
||||
import ConnectivityOptions from "./connectivity/ConnectivityOptions";
|
||||
import NTPConnectivity from "./connectivity/NTPConnectivity";
|
||||
import AuthSettings from "./connectivity/AuthSettings";
|
||||
import WifiConnectivity from "./connectivity/WifiConnectivity";
|
||||
import NetworkAdvertisementSettings from "./connectivity/NetworkAdvertisementSettings";
|
||||
import NTPConnectivityPage from "./connectivity/NTPConnectivityPage";
|
||||
import AuthSettingsPage from "./connectivity/AuthSettingsPage";
|
||||
import WifiConnectivityPage from "./connectivity/WifiConnectivityPage";
|
||||
import NetworkAdvertisementSettingsPage from "./connectivity/NetworkAdvertisementSettingsPage";
|
||||
import RobotCoverageMapPage from "../map/RobotCoverageMapPage";
|
||||
import ValetudoOptions from "./ValetudoOptions";
|
||||
import React from "react";
|
||||
import RobotOptions from "../robot/RobotOptions";
|
||||
import MiscRobotOptions from "../robot/capabilities/MiscRobotOptions";
|
||||
import Quirks from "../robot/capabilities/Quirks";
|
||||
import MQTTConnectivityPage from "./connectivity/MQTTConnectivityPage";
|
||||
|
||||
const OptionsRouter = (): React.ReactElement => {
|
||||
const {path} = useRouteMatch();
|
||||
@ -70,21 +70,21 @@ const OptionsRouter = (): React.ReactElement => {
|
||||
<ConnectivityOptions/>
|
||||
</Route>
|
||||
<Route exact path={path + "/connectivity/auth"}>
|
||||
<AuthSettings/>
|
||||
<AuthSettingsPage/>
|
||||
</Route>
|
||||
<Route exact path={path + "/connectivity/mqtt"}>
|
||||
<MQTTConnectivity/>
|
||||
<MQTTConnectivityPage/>
|
||||
</Route>
|
||||
<Route exact path={path + "/connectivity/networkadvertisement"}>
|
||||
<NetworkAdvertisementSettings/>
|
||||
<NetworkAdvertisementSettingsPage/>
|
||||
</Route>
|
||||
<Route exact path={path + "/connectivity/ntp"}>
|
||||
<NTPConnectivity/>
|
||||
<NTPConnectivityPage/>
|
||||
</Route>
|
||||
{
|
||||
wifiConfigurationCapabilitySupported &&
|
||||
<Route exact path={path + "/connectivity/wifi"}>
|
||||
<WifiConnectivity/>
|
||||
<WifiConnectivityPage/>
|
||||
</Route>
|
||||
}
|
||||
|
||||
|
||||
@ -1,180 +0,0 @@
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
Input,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import {useHTTPBasicAuthConfigurationMutation, useHTTPBasicAuthConfigurationQuery} from "../../api";
|
||||
import LoadingFade from "../../components/LoadingFade";
|
||||
import {LoadingButton} from "@mui/lab";
|
||||
import InfoBox from "../../components/InfoBox";
|
||||
import PaperContainer from "../../components/PaperContainer";
|
||||
import {
|
||||
Visibility as VisibilityIcon,
|
||||
VisibilityOff as VisibilityOffIcon,
|
||||
VpnKey as BasicAuthIcon
|
||||
} from "@mui/icons-material";
|
||||
import DetailPageHeaderRow from "../../components/DetailPageHeaderRow";
|
||||
|
||||
const AuthSettings = (): React.ReactElement => {
|
||||
const {
|
||||
data: storedConfiguration,
|
||||
isPending: configurationPending,
|
||||
isError: configurationError,
|
||||
} = useHTTPBasicAuthConfigurationQuery();
|
||||
|
||||
const {mutate: updateConfiguration, isPending: configurationUpdating} = useHTTPBasicAuthConfigurationMutation();
|
||||
|
||||
const [enabled, setEnabled] = React.useState(false);
|
||||
const [username, setUsername] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
|
||||
const [showPasswordAsPlain, setShowPasswordAsPlain] = React.useState(false);
|
||||
const [configurationModified, setConfigurationModified] = React.useState<boolean>(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (storedConfiguration) {
|
||||
setEnabled(storedConfiguration.enabled);
|
||||
setUsername(storedConfiguration.username);
|
||||
setPassword(storedConfiguration.password);
|
||||
}
|
||||
}, [storedConfiguration]);
|
||||
|
||||
if (configurationPending) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
);
|
||||
}
|
||||
|
||||
if (configurationError || !storedConfiguration) {
|
||||
return <Typography color="error">Error loading HTTP Basic Auth configuration</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PaperContainer>
|
||||
<Grid container direction="row">
|
||||
<Box style={{width: "100%"}}>
|
||||
<DetailPageHeaderRow
|
||||
title="HTTP Basic Auth"
|
||||
icon={<BasicAuthIcon/>}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onChange={e => {
|
||||
setEnabled(e.target.checked);
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="HTTP Basic Auth enabled"
|
||||
sx={{mb: 1}}
|
||||
/>
|
||||
<Grid container spacing={1} sx={{mb: 1}} direction="row">
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width: "100%"}}
|
||||
label="Username"
|
||||
value={username}
|
||||
variant="standard"
|
||||
disabled={!enabled}
|
||||
onChange={e => {
|
||||
setUsername(e.target.value);
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<FormControl style={{width: "100%"}} variant="standard">
|
||||
<InputLabel htmlFor="standard-adornment-password">Password</InputLabel>
|
||||
<Input
|
||||
type={showPasswordAsPlain ? "text" : "password"}
|
||||
fullWidth
|
||||
value={password}
|
||||
disabled={!enabled}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={() => {
|
||||
setShowPasswordAsPlain(!showPasswordAsPlain);
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
edge="end"
|
||||
>
|
||||
{showPasswordAsPlain ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setConfigurationModified(true);
|
||||
}}/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<InfoBox
|
||||
boxShadow={5}
|
||||
style={{
|
||||
marginTop: "3rem",
|
||||
marginBottom: "2rem"
|
||||
}}
|
||||
>
|
||||
<Typography color="info">
|
||||
Valetudo will by default try to block access from public-routable IP addresses
|
||||
for your safety and convenience.
|
||||
<br/><br/>
|
||||
If you want to allow external access to your Valetudo instance, consider using a VPN such as
|
||||
WireGuard or OpenVPN to ensure the safety of your network.
|
||||
<br/><br/>
|
||||
If you don't want to use a VPN, usage of a reverse proxy in front of Valetudo and all of your other
|
||||
IoT things and network services is strongly recommended, as a recent version of a proper WebServer
|
||||
such as nginx, the Apache HTTP Server or similar will likely be more secure than Valetudo itself.
|
||||
<br/>
|
||||
Moreover, this approach will group all access logs to all services in a single place.
|
||||
It's also much easier to implement some kind of Single sign-on that way.
|
||||
</Typography>
|
||||
</InfoBox>
|
||||
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
<Grid container>
|
||||
<Grid item style={{marginLeft: "auto"}}>
|
||||
<LoadingButton
|
||||
loading={configurationUpdating}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
disabled={!configurationModified}
|
||||
onClick={() => {
|
||||
updateConfiguration({
|
||||
enabled: enabled,
|
||||
username: username,
|
||||
password: password
|
||||
});
|
||||
setConfigurationModified(false);
|
||||
}}
|
||||
>
|
||||
Save configuration
|
||||
</LoadingButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Grid>
|
||||
</PaperContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthSettings;
|
||||
188
frontend/src/options/connectivity/AuthSettingsPage.tsx
Normal file
188
frontend/src/options/connectivity/AuthSettingsPage.tsx
Normal file
@ -0,0 +1,188 @@
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
Input,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
Skeleton,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import {useHTTPBasicAuthConfigurationMutation, useHTTPBasicAuthConfigurationQuery} from "../../api";
|
||||
import {LoadingButton} from "@mui/lab";
|
||||
import InfoBox from "../../components/InfoBox";
|
||||
import PaperContainer from "../../components/PaperContainer";
|
||||
import {
|
||||
Visibility as VisibilityIcon,
|
||||
VisibilityOff as VisibilityOffIcon,
|
||||
VpnKey as BasicAuthIcon
|
||||
} from "@mui/icons-material";
|
||||
import DetailPageHeaderRow from "../../components/DetailPageHeaderRow";
|
||||
|
||||
const AuthSettings = (): React.ReactElement => {
|
||||
const {
|
||||
data: storedConfiguration,
|
||||
isPending: configurationPending,
|
||||
isError: configurationError,
|
||||
} = useHTTPBasicAuthConfigurationQuery();
|
||||
|
||||
const {mutate: updateConfiguration, isPending: configurationUpdating} = useHTTPBasicAuthConfigurationMutation();
|
||||
|
||||
const [enabled, setEnabled] = React.useState(false);
|
||||
const [username, setUsername] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
|
||||
const [showPasswordAsPlain, setShowPasswordAsPlain] = React.useState(false);
|
||||
const [configurationModified, setConfigurationModified] = React.useState<boolean>(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (storedConfiguration) {
|
||||
setEnabled(storedConfiguration.enabled);
|
||||
setUsername(storedConfiguration.username);
|
||||
setPassword(storedConfiguration.password);
|
||||
}
|
||||
}, [storedConfiguration]);
|
||||
|
||||
if (configurationPending) {
|
||||
return (
|
||||
<Skeleton height={"8rem"}/>
|
||||
);
|
||||
}
|
||||
|
||||
if (configurationError || !storedConfiguration) {
|
||||
return <Typography color="error">Error loading HTTP Basic Auth configuration</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onChange={e => {
|
||||
setEnabled(e.target.checked);
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="HTTP Basic Auth enabled"
|
||||
sx={{mb: 1}}
|
||||
/>
|
||||
<Grid container spacing={1} sx={{mb: 1}} direction="row">
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width: "100%"}}
|
||||
label="Username"
|
||||
value={username}
|
||||
variant="standard"
|
||||
disabled={!enabled}
|
||||
onChange={e => {
|
||||
setUsername(e.target.value);
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<FormControl style={{width: "100%"}} variant="standard">
|
||||
<InputLabel htmlFor="standard-adornment-password">Password</InputLabel>
|
||||
<Input
|
||||
type={showPasswordAsPlain ? "text" : "password"}
|
||||
fullWidth
|
||||
value={password}
|
||||
disabled={!enabled}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={() => {
|
||||
setShowPasswordAsPlain(!showPasswordAsPlain);
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
edge="end"
|
||||
>
|
||||
{showPasswordAsPlain ? <VisibilityOffIcon/> : <VisibilityIcon/>}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value);
|
||||
setConfigurationModified(true);
|
||||
}}/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<InfoBox
|
||||
boxShadow={5}
|
||||
style={{
|
||||
marginTop: "3rem",
|
||||
marginBottom: "2rem"
|
||||
}}
|
||||
>
|
||||
<Typography color="info">
|
||||
Valetudo will by default try to block access from public-routable IP addresses
|
||||
for your safety and convenience.
|
||||
<br/><br/>
|
||||
If you want to allow external access to your Valetudo instance, consider using a VPN such as
|
||||
WireGuard or OpenVPN to ensure the safety of your network.
|
||||
<br/><br/>
|
||||
If you don't want to use a VPN, usage of a reverse proxy in front of Valetudo and all of your
|
||||
other
|
||||
IoT things and network services is strongly recommended, as a recent version of a proper WebServer
|
||||
such as nginx, the Apache HTTP Server or similar will likely be more secure than Valetudo itself.
|
||||
<br/>
|
||||
Moreover, this approach will group all access logs to all services in a single place.
|
||||
It's also much easier to implement some kind of Single sign-on that way.
|
||||
</Typography>
|
||||
</InfoBox>
|
||||
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
<Grid container>
|
||||
<Grid item style={{marginLeft: "auto"}}>
|
||||
<LoadingButton
|
||||
loading={configurationUpdating}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
disabled={!configurationModified}
|
||||
onClick={() => {
|
||||
updateConfiguration({
|
||||
enabled: enabled,
|
||||
username: username,
|
||||
password: password
|
||||
});
|
||||
setConfigurationModified(false);
|
||||
}}
|
||||
>
|
||||
Save configuration
|
||||
</LoadingButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AuthSettingsPage = (): React.ReactElement => {
|
||||
return (
|
||||
<PaperContainer>
|
||||
<Grid container direction="row">
|
||||
<Box style={{width: "100%"}}>
|
||||
<DetailPageHeaderRow
|
||||
title="HTTP Basic Auth"
|
||||
icon={<BasicAuthIcon/>}
|
||||
/>
|
||||
<AuthSettings/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</PaperContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthSettingsPage;
|
||||
@ -1,855 +0,0 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Container,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
Input,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
Popper,
|
||||
Switch,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
ArrowUpward,
|
||||
Visibility as VisibilityIcon,
|
||||
VisibilityOff as VisibilityOffIcon,
|
||||
|
||||
LinkOff as MQTTDisconnectedIcon,
|
||||
Link as MQTTConnectedIcon,
|
||||
Sync as MQTTConnectingIcon,
|
||||
Warning as MQTTErrorIcon,
|
||||
} from "@mui/icons-material";
|
||||
import React from "react";
|
||||
import {
|
||||
MQTTConfiguration,
|
||||
MQTTStatus,
|
||||
useMQTTConfigurationMutation,
|
||||
useMQTTConfigurationQuery,
|
||||
useMQTTPropertiesQuery,
|
||||
useMQTTStatusQuery
|
||||
} from "../../api";
|
||||
import {getIn, setIn} from "../../api/utils";
|
||||
import {convertBytesToHumans, deepCopy, extractHostFromUrl} from "../../utils";
|
||||
import {InputProps} from "@mui/material/Input/Input";
|
||||
import LoadingFade from "../../components/LoadingFade";
|
||||
import InfoBox from "../../components/InfoBox";
|
||||
import PaperContainer from "../../components/PaperContainer";
|
||||
import {MQTTIcon} from "../../components/CustomIcons";
|
||||
import {LoadingButton} from "@mui/lab";
|
||||
import TextInformationGrid from "../../components/TextInformationGrid";
|
||||
import DetailPageHeaderRow from "../../components/DetailPageHeaderRow";
|
||||
|
||||
const MQTTStatusComponent : React.FunctionComponent<{ status: MQTTStatus | undefined, statusLoading: boolean, statusError: boolean }> = ({
|
||||
status,
|
||||
statusLoading,
|
||||
statusError
|
||||
}) => {
|
||||
|
||||
if (statusLoading || !status) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
);
|
||||
}
|
||||
|
||||
if (statusError) {
|
||||
return <Typography color="error">Error loading MQTT status</Typography>;
|
||||
}
|
||||
|
||||
const getIconForState = () : React.ReactElement => {
|
||||
switch (status.state) {
|
||||
case "disconnected":
|
||||
return <MQTTDisconnectedIcon sx={{ fontSize: "4rem" }}/>;
|
||||
case "ready":
|
||||
return <MQTTConnectedIcon sx={{ fontSize: "4rem" }}/>;
|
||||
case "init":
|
||||
return <MQTTConnectingIcon sx={{ fontSize: "4rem" }}/>;
|
||||
case "lost":
|
||||
case "alert":
|
||||
return <MQTTErrorIcon sx={{fontSize: "4rem"}}/>;
|
||||
}
|
||||
};
|
||||
|
||||
const getContentForState = () : React.ReactElement => {
|
||||
switch (status.state) {
|
||||
case "disconnected":
|
||||
return (
|
||||
<Typography variant="h5">Disconnected</Typography>
|
||||
);
|
||||
case "ready":
|
||||
return (
|
||||
<Typography variant="h5">Connected</Typography>
|
||||
);
|
||||
case "init":
|
||||
return (
|
||||
<Typography variant="h5">Connecting/Reconfiguring</Typography>
|
||||
);
|
||||
case "lost":
|
||||
case "alert":
|
||||
return (
|
||||
<Typography variant="h5">Connection error</Typography>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getMessageStats = () : React.ReactElement => {
|
||||
const items = [
|
||||
{
|
||||
header: "Messages Sent",
|
||||
body: status.stats.messages.count.sent.toString()
|
||||
},
|
||||
{
|
||||
header: "Bytes Sent",
|
||||
body: convertBytesToHumans(status.stats.messages.bytes.sent)
|
||||
},
|
||||
{
|
||||
header: "Messages Received",
|
||||
body: status.stats.messages.count.received.toString()
|
||||
},
|
||||
{
|
||||
header: "Bytes Received",
|
||||
body: convertBytesToHumans(status.stats.messages.bytes.received)
|
||||
},
|
||||
];
|
||||
|
||||
return <TextInformationGrid items={items}/>;
|
||||
};
|
||||
|
||||
const getConnectionStats = () : React.ReactElement => {
|
||||
const items = [
|
||||
{
|
||||
header: "Connects",
|
||||
body: status.stats.connection.connects.toString()
|
||||
},
|
||||
{
|
||||
header: "Disconnects",
|
||||
body: status.stats.connection.disconnects.toString()
|
||||
},
|
||||
{
|
||||
header: "Reconnects",
|
||||
body: status.stats.connection.reconnects.toString()
|
||||
},
|
||||
{
|
||||
header: "Errors",
|
||||
body: status.stats.connection.errors.toString()
|
||||
},
|
||||
];
|
||||
|
||||
return <TextInformationGrid items={items}/>;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Grid container alignItems="center" direction="column" style={{paddingBottom:"1rem"}}>
|
||||
<Grid item style={{marginTop:"1rem"}}>
|
||||
{getIconForState()}
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
maxWidth: "100% !important", //Why, MUI? Why?
|
||||
wordWrap: "break-word",
|
||||
textAlign: "center",
|
||||
userSelect: "none"
|
||||
}}
|
||||
>
|
||||
{getContentForState()}
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
direction="row"
|
||||
style={{marginTop: "1rem"}}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
style={{flexGrow: 1}}
|
||||
p={1}
|
||||
>
|
||||
<Card
|
||||
sx={{boxShadow: 3}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Message Statistics
|
||||
</Typography>
|
||||
<Divider/>
|
||||
{getMessageStats()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
style={{flexGrow: 1}}
|
||||
p={1}
|
||||
>
|
||||
<Card
|
||||
sx={{boxShadow: 3}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Connection Statistics
|
||||
</Typography>
|
||||
<Divider/>
|
||||
{getConnectionStats()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const GroupBox = (props: { title: string, children: React.ReactNode, checked?: boolean, disabled?: boolean, onChange?: ((event: React.ChangeEvent<HTMLInputElement>) => void) }): React.ReactElement => {
|
||||
let title = (
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
marginBottom: 0,
|
||||
userSelect: "none"
|
||||
}}
|
||||
>
|
||||
{props.title}
|
||||
</Typography>
|
||||
);
|
||||
if (props.onChange) {
|
||||
title = (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
}
|
||||
disableTypography
|
||||
label={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container sx={{m: 0.2}}>
|
||||
{title}
|
||||
<Collapse in={props.checked || !props.onChange} appear={false}>
|
||||
<div>
|
||||
{props.children}
|
||||
</div>
|
||||
</Collapse>
|
||||
<Box pt={1}/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const MQTTInput : React.FunctionComponent<{
|
||||
mqttConfiguration: MQTTConfiguration,
|
||||
modifyMQTTConfig: (value: any, configPath: Array<string>) => void,
|
||||
disabled?: boolean,
|
||||
|
||||
title: string,
|
||||
helperText: string,
|
||||
required: boolean,
|
||||
configPath: Array<string>,
|
||||
additionalProps?: InputProps
|
||||
inputPostProcessor?: (value: any) => any
|
||||
}> = ({
|
||||
mqttConfiguration,
|
||||
modifyMQTTConfig,
|
||||
disabled = false,
|
||||
|
||||
title,
|
||||
helperText,
|
||||
required,
|
||||
configPath,
|
||||
additionalProps,
|
||||
inputPostProcessor
|
||||
}) => {
|
||||
const idBase = "mqtt-config-" + configPath.join("-");
|
||||
const inputId = idBase + "-input";
|
||||
const helperId = idBase + "-helper";
|
||||
const value = getIn(mqttConfiguration, configPath);
|
||||
const error = required && !value;
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
required={required}
|
||||
error={error}
|
||||
component="fieldset"
|
||||
sx={{ml: 1, mt: 2}}
|
||||
|
||||
>
|
||||
<InputLabel htmlFor={inputId} >{title}</InputLabel>
|
||||
<Input
|
||||
id={inputId}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
let newValue = additionalProps?.type === "number" ? parseInt(e.target.value) : e.target.value;
|
||||
if (inputPostProcessor) {
|
||||
newValue = inputPostProcessor(newValue);
|
||||
}
|
||||
|
||||
modifyMQTTConfig(newValue, configPath);
|
||||
}}
|
||||
aria-describedby={helperId}
|
||||
|
||||
{...additionalProps}
|
||||
/>
|
||||
<FormHelperText id={helperId} sx={{userSelect: "none"}}>
|
||||
{helperText}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
const MQTTSwitch : React.FunctionComponent<{
|
||||
mqttConfiguration: MQTTConfiguration,
|
||||
modifyMQTTConfig: (value: any, configPath: Array<string>) => void,
|
||||
disabled?: boolean,
|
||||
|
||||
title: string,
|
||||
configPath: Array<string>,
|
||||
}> = ({
|
||||
mqttConfiguration,
|
||||
modifyMQTTConfig,
|
||||
disabled = false,
|
||||
|
||||
title,
|
||||
configPath,
|
||||
}) => {
|
||||
const value = getIn(mqttConfiguration, configPath);
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={value} onChange={(e) => {
|
||||
modifyMQTTConfig(e.target.checked, configPath);
|
||||
}}/>
|
||||
}
|
||||
|
||||
label={title}
|
||||
sx={{userSelect: "none"}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const MQTTOptionalExposedCapabilitiesEditor : React.FunctionComponent<{
|
||||
mqttConfiguration: MQTTConfiguration,
|
||||
modifyMQTTConfig: (value: any, configPath: Array<string>) => void,
|
||||
disabled?: boolean,
|
||||
|
||||
configPath: Array<string>,
|
||||
exposableCapabilities: Array<string>
|
||||
}> = ({
|
||||
mqttConfiguration,
|
||||
modifyMQTTConfig,
|
||||
disabled = false,
|
||||
|
||||
configPath,
|
||||
exposableCapabilities
|
||||
}) => {
|
||||
let selection: Array<string> = getIn(mqttConfiguration, configPath);
|
||||
|
||||
return (
|
||||
<Container sx={{m: 0.2}}>
|
||||
<FormGroup>
|
||||
{
|
||||
exposableCapabilities.map((capabilityName : string) => {
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={capabilityName}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selection.includes(capabilityName)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
selection.push(capabilityName);
|
||||
} else {
|
||||
selection = selection.filter(e => {
|
||||
return e !== capabilityName;
|
||||
});
|
||||
}
|
||||
|
||||
modifyMQTTConfig(selection, configPath);
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
||||
label={capabilityName}
|
||||
sx={{userSelect: "none"}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
</FormGroup>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeStringForMQTT = (value: string, allowSlashes = false) => {
|
||||
/*
|
||||
This rather limited set of characters is unfortunately required by Home Assistant
|
||||
Without Home Assistant, it would be enough to replace [\s+#/]
|
||||
|
||||
See also: https://www.home-assistant.io/docs/mqtt/discovery/#discovery-topic
|
||||
*/
|
||||
return value.replace(
|
||||
allowSlashes ? /[^a-zA-Z0-9_\-/]/g : /[^a-zA-Z0-9_-]/g,
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeTopicPrefix = (value: string) => {
|
||||
return value.replace(
|
||||
/^\//,
|
||||
""
|
||||
).replace(
|
||||
/\/$/,
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeConfigBeforeSaving = (mqttConfiguration: MQTTConfiguration) => {
|
||||
mqttConfiguration.customizations.topicPrefix = sanitizeTopicPrefix(mqttConfiguration.customizations.topicPrefix);
|
||||
};
|
||||
|
||||
const MQTTConnectivity = (): React.ReactElement => {
|
||||
const theme = useTheme();
|
||||
|
||||
const [anchorElement, setAnchorElement] = React.useState(null);
|
||||
|
||||
const identifierElement = React.useRef(null);
|
||||
const topicElement = React.useRef(null);
|
||||
|
||||
const {
|
||||
data: storedMQTTConfiguration,
|
||||
isPending: mqttConfigurationPending,
|
||||
isError: mqttConfigurationError,
|
||||
} = useMQTTConfigurationQuery();
|
||||
|
||||
const {
|
||||
data: mqttStatus,
|
||||
isPending: mqttStatusPending,
|
||||
isFetching: mqttStatusFetching,
|
||||
isError: mqttStatusError,
|
||||
refetch: refetchMqttStatus,
|
||||
} = useMQTTStatusQuery();
|
||||
|
||||
const {
|
||||
data: mqttProperties,
|
||||
isPending: mqttPropertiesPending,
|
||||
isError: mqttPropertiesError
|
||||
} = useMQTTPropertiesQuery();
|
||||
|
||||
const {mutate: updateMQTTConfiguration, isPending: mqttConfigurationUpdating} = useMQTTConfigurationMutation();
|
||||
|
||||
const [mqttConfiguration, setMQTTConfiguration] = React.useState<MQTTConfiguration | null>(null);
|
||||
const [configurationModified, setConfigurationModified] = React.useState<boolean>(false);
|
||||
|
||||
|
||||
const [showMQTTAuthPasswordAsPlain, setShowMQTTAuthPasswordAsPlain] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (storedMQTTConfiguration && !configurationModified && !mqttConfigurationUpdating) {
|
||||
setMQTTConfiguration(deepCopy(storedMQTTConfiguration));
|
||||
setConfigurationModified(false);
|
||||
}
|
||||
}, [storedMQTTConfiguration, configurationModified, mqttConfigurationUpdating]);
|
||||
|
||||
const modifyMQTTConfig = React.useCallback((value: any, configPath: Array<string>): void => {
|
||||
if (!mqttConfiguration) {
|
||||
return;
|
||||
}
|
||||
const newConfig = deepCopy(mqttConfiguration);
|
||||
setIn(newConfig, value, configPath);
|
||||
setMQTTConfiguration(newConfig);
|
||||
setConfigurationModified(true);
|
||||
}, [mqttConfiguration]);
|
||||
|
||||
if (mqttConfigurationPending || mqttPropertiesPending || !mqttConfiguration) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
);
|
||||
}
|
||||
|
||||
if (mqttConfigurationError || mqttPropertiesError || !storedMQTTConfiguration || !mqttProperties) {
|
||||
return <Typography color="error">Error loading MQTT configuration</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PaperContainer>
|
||||
<Grid container direction="row">
|
||||
<Box style={{width: "100%"}}>
|
||||
<DetailPageHeaderRow
|
||||
title="MQTT Connectivity"
|
||||
icon={<MQTTIcon/>}
|
||||
onRefreshClick={() => {
|
||||
refetchMqttStatus().catch(() => {
|
||||
/* intentional */
|
||||
});
|
||||
}}
|
||||
isRefreshing={mqttStatusFetching}
|
||||
/>
|
||||
|
||||
<MQTTStatusComponent
|
||||
status={mqttStatus}
|
||||
statusLoading={mqttStatusPending}
|
||||
statusError={mqttStatusError}
|
||||
/>
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={mqttConfiguration.enabled}
|
||||
onChange={e => {
|
||||
modifyMQTTConfig(e.target.checked, ["enabled"]);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="MQTT enabled"
|
||||
sx={{userSelect: "none", marginLeft: "0.5rem", marginBottom: "0.5rem"}}
|
||||
/>
|
||||
|
||||
<GroupBox title="Connection">
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Host"
|
||||
helperText="The MQTT Broker hostname"
|
||||
required={true}
|
||||
configPath={["connection", "host"]}
|
||||
inputPostProcessor={(value) => {
|
||||
return extractHostFromUrl(value);
|
||||
}}
|
||||
/>
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Port"
|
||||
helperText="The MQTT Broker port"
|
||||
required={true}
|
||||
configPath={["connection", "port"]}
|
||||
additionalProps={{type: "number"}}
|
||||
/>
|
||||
|
||||
<GroupBox title="TLS" checked={mqttConfiguration.connection.tls.enabled}
|
||||
onChange={(e) => {
|
||||
modifyMQTTConfig(e.target.checked, ["connection", "tls", "enabled"]);
|
||||
}}>
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="CA"
|
||||
helperText="The optional Certificate Authority to verify the connection with"
|
||||
required={false}
|
||||
configPath={["connection", "tls", "ca"]}
|
||||
additionalProps={{
|
||||
multiline: true,
|
||||
minRows: 3,
|
||||
maxRows: 10,
|
||||
}}
|
||||
/>
|
||||
<br/><br/>
|
||||
<MQTTSwitch
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
title="Ignore certificate errors"
|
||||
configPath={["connection", "tls", "ignoreCertificateErrors"]}
|
||||
/>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox title="Authentication">
|
||||
<GroupBox title="Credentials"
|
||||
checked={mqttConfiguration.connection.authentication.credentials.enabled}
|
||||
onChange={(e) => {
|
||||
modifyMQTTConfig(e.target.checked, ["connection", "authentication", "credentials", "enabled"]);
|
||||
}}>
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Username"
|
||||
helperText="Username for authentication"
|
||||
required={true}
|
||||
configPath={["connection", "authentication", "credentials", "username"]}
|
||||
/>
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Password"
|
||||
helperText="Password for authentication"
|
||||
required={false}
|
||||
configPath={["connection", "authentication", "credentials", "password"]}
|
||||
additionalProps={{
|
||||
type: showMQTTAuthPasswordAsPlain ? "text" : "password",
|
||||
endAdornment : (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={() => {
|
||||
setShowMQTTAuthPasswordAsPlain(!showMQTTAuthPasswordAsPlain);
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
edge="end"
|
||||
>
|
||||
{showMQTTAuthPasswordAsPlain ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</GroupBox>
|
||||
<GroupBox title="Client certificate"
|
||||
checked={mqttConfiguration.connection.authentication.clientCertificate.enabled}
|
||||
onChange={(e) => {
|
||||
modifyMQTTConfig(e.target.checked, ["connection", "authentication", "clientCertificate", "enabled"]);
|
||||
}}>
|
||||
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Certificate"
|
||||
helperText="The full certificate as a multi-line string"
|
||||
required={true}
|
||||
configPath={["connection", "authentication", "clientCertificate", "certificate"]}
|
||||
additionalProps={{
|
||||
multiline: true,
|
||||
minRows: 3,
|
||||
maxRows: 10
|
||||
}}
|
||||
/>
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Key"
|
||||
helperText="The full key as a multi-line string"
|
||||
required={true}
|
||||
configPath={["connection", "authentication", "clientCertificate", "key"]}
|
||||
additionalProps={{
|
||||
multiline: true,
|
||||
minRows: 3,
|
||||
maxRows: 10
|
||||
}}
|
||||
/>
|
||||
</GroupBox>
|
||||
</GroupBox>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox title="Customizations" >
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Topic prefix"
|
||||
helperText="MQTT topic prefix"
|
||||
required={false}
|
||||
configPath={["customizations", "topicPrefix"]}
|
||||
additionalProps={{
|
||||
placeholder: mqttProperties.defaults.customizations.topicPrefix,
|
||||
color: "warning",
|
||||
onFocus: () => {
|
||||
setAnchorElement(topicElement.current);
|
||||
},
|
||||
onBlur: () => {
|
||||
setAnchorElement(null);
|
||||
},
|
||||
}}
|
||||
inputPostProcessor={(value) => {
|
||||
return sanitizeStringForMQTT(
|
||||
value,
|
||||
true
|
||||
).replace(
|
||||
/\/\//g,
|
||||
"/"
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Identifier"
|
||||
helperText="The machine-readable name of the robot"
|
||||
required={false}
|
||||
configPath={["identity", "identifier"]}
|
||||
additionalProps={{
|
||||
placeholder: mqttProperties.defaults.identity.identifier,
|
||||
color: "secondary",
|
||||
onFocus: () => {
|
||||
setAnchorElement(identifierElement.current);
|
||||
},
|
||||
onBlur: () => {
|
||||
setAnchorElement(null);
|
||||
},
|
||||
}}
|
||||
inputPostProcessor={(value) => {
|
||||
return sanitizeStringForMQTT(value, false);
|
||||
}}
|
||||
/>
|
||||
<br/>
|
||||
<Typography variant="subtitle2" sx={{mt: "0.5rem", mb: "2rem", userSelect: "none"}} noWrap={false}>
|
||||
The MQTT Topic structure will look like this:<br/>
|
||||
<span style={{fontFamily: "\"JetBrains Mono\",monospace", fontWeight: 200, overflowWrap: "anywhere", userSelect: "text"}}>
|
||||
<span
|
||||
style={{
|
||||
color: theme.palette.warning.main
|
||||
}}
|
||||
ref={topicElement}
|
||||
>
|
||||
{sanitizeTopicPrefix(mqttConfiguration.customizations.topicPrefix) || mqttProperties.defaults.customizations.topicPrefix}
|
||||
</span>
|
||||
/<wbr/>
|
||||
<span
|
||||
style={{
|
||||
color: theme.palette.secondary.main
|
||||
}}
|
||||
ref={identifierElement}
|
||||
>
|
||||
{mqttConfiguration.identity.identifier || mqttProperties.defaults.identity.identifier}
|
||||
</span>
|
||||
/<wbr/>BatteryStateAttribute/<wbr/>level
|
||||
</span>
|
||||
</Typography>
|
||||
<MQTTSwitch
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
title="Provide map data"
|
||||
configPath={["customizations", "provideMapData"]}
|
||||
/>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox title="Interfaces" >
|
||||
<GroupBox title="Homie" checked={mqttConfiguration.interfaces.homie.enabled}
|
||||
onChange={(e) => {
|
||||
modifyMQTTConfig(e.target.checked, ["interfaces", "homie", "enabled"]);
|
||||
}}>
|
||||
<FormControl component="fieldset" variant="standard">
|
||||
<FormLabel component="legend" sx={{userSelect: "none"}}>Select the options for Homie integration</FormLabel>
|
||||
<FormGroup>
|
||||
<MQTTSwitch
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
title={"Provide autodiscovery for \"I Can't Believe It's Not Valetudo\" map"}
|
||||
configPath={["interfaces", "homie", "addICBINVMapProperty"]}
|
||||
/>
|
||||
<MQTTSwitch
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
title="Delete autodiscovery on shutdown"
|
||||
configPath={["interfaces", "homie", "cleanAttributesOnShutdown"]}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox title="Home Assistant" checked={mqttConfiguration.interfaces.homeassistant.enabled}
|
||||
onChange={(e) => {
|
||||
modifyMQTTConfig(e.target.checked, ["interfaces", "homeassistant", "enabled"]);
|
||||
}}>
|
||||
<FormControl component="fieldset" variant="standard">
|
||||
<FormLabel component="legend" sx={{userSelect: "none"}}>Select the options for Home Assistant integration</FormLabel>
|
||||
<FormGroup>
|
||||
<MQTTSwitch
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
title="Delete autodiscovery on shutdown"
|
||||
configPath={["interfaces", "homeassistant", "cleanAutoconfOnShutdown"]}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
</GroupBox>
|
||||
</GroupBox>
|
||||
|
||||
{
|
||||
mqttProperties.optionalExposableCapabilities.length > 0 &&
|
||||
<GroupBox title="Optional exposable capabilities" >
|
||||
<MQTTOptionalExposedCapabilitiesEditor
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
configPath={["optionalExposedCapabilities"]}
|
||||
exposableCapabilities={mqttProperties.optionalExposableCapabilities}
|
||||
/>
|
||||
</GroupBox>
|
||||
}
|
||||
|
||||
<Popper
|
||||
open={Boolean(anchorElement)}
|
||||
anchorEl={anchorElement}
|
||||
>
|
||||
<Box>
|
||||
<ArrowUpward fontSize={"large"} color={"info"}/>
|
||||
</Box>
|
||||
</Popper>
|
||||
|
||||
<InfoBox
|
||||
boxShadow={5}
|
||||
style={{
|
||||
marginTop: "2rem",
|
||||
marginBottom: "2rem"
|
||||
}}
|
||||
>
|
||||
<Typography color="info">
|
||||
Valetudo recommends the use of the Eclipse Mosquitto MQTT Broker, which is FOSS, has a
|
||||
tiny resource footprint and is part of basically every GNU/Linux distribution.
|
||||
You can also install it as a container via the container management solution of your choice.
|
||||
|
||||
<br/><br/>
|
||||
If you're experiencing problems regarding MQTT, make sure to try Mosquitto since some other MQTT
|
||||
brokers only implement a subset of the MQTT spec, which often leads to issues when used with Valetudo.
|
||||
|
||||
<br/><br/>
|
||||
If you're using Mosquitto but still experience issues, make sure that your ACLs (if any) are correct and
|
||||
you're also using the correct login credentials for those.
|
||||
Valetudo will not receive any feedback from the broker if publishing fails due to ACL restrictions as such feedback
|
||||
simply isn't part of the MQTT v3.1.1 spec. MQTT v5 fixes this issue but isn't widely available just yet.
|
||||
</Typography>
|
||||
</InfoBox>
|
||||
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
|
||||
<Grid container>
|
||||
<Grid item style={{marginLeft: "auto"}}>
|
||||
<LoadingButton
|
||||
disabled={!configurationModified}
|
||||
loading={mqttConfigurationUpdating}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
sanitizeConfigBeforeSaving(mqttConfiguration);
|
||||
|
||||
updateMQTTConfiguration(mqttConfiguration);
|
||||
setConfigurationModified(false);
|
||||
}}
|
||||
>
|
||||
Save configuration
|
||||
</LoadingButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Grid>
|
||||
</PaperContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default MQTTConnectivity;
|
||||
891
frontend/src/options/connectivity/MQTTConnectivityPage.tsx
Normal file
891
frontend/src/options/connectivity/MQTTConnectivityPage.tsx
Normal file
@ -0,0 +1,891 @@
|
||||
import {
|
||||
Box,
|
||||
Card,
|
||||
CardContent,
|
||||
Checkbox,
|
||||
Collapse,
|
||||
Container,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
FormGroup,
|
||||
FormHelperText,
|
||||
FormLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
Input,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
Popper,
|
||||
Skeleton,
|
||||
Switch,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
ArrowUpward,
|
||||
Visibility as VisibilityIcon,
|
||||
VisibilityOff as VisibilityOffIcon,
|
||||
|
||||
LinkOff as MQTTDisconnectedIcon,
|
||||
Link as MQTTConnectedIcon,
|
||||
Sync as MQTTConnectingIcon,
|
||||
Warning as MQTTErrorIcon,
|
||||
} from "@mui/icons-material";
|
||||
import React from "react";
|
||||
import {
|
||||
MQTTConfiguration,
|
||||
MQTTStatus,
|
||||
useMQTTConfigurationMutation,
|
||||
useMQTTConfigurationQuery,
|
||||
useMQTTPropertiesQuery,
|
||||
useMQTTStatusQuery
|
||||
} from "../../api";
|
||||
import {getIn, setIn} from "../../api/utils";
|
||||
import {convertBytesToHumans, deepCopy, extractHostFromUrl} from "../../utils";
|
||||
import {InputProps} from "@mui/material/Input/Input";
|
||||
import InfoBox from "../../components/InfoBox";
|
||||
import PaperContainer from "../../components/PaperContainer";
|
||||
import {MQTTIcon} from "../../components/CustomIcons";
|
||||
import {LoadingButton} from "@mui/lab";
|
||||
import TextInformationGrid from "../../components/TextInformationGrid";
|
||||
import DetailPageHeaderRow from "../../components/DetailPageHeaderRow";
|
||||
|
||||
const MQTTStatusComponent: React.FunctionComponent<{
|
||||
status: MQTTStatus | undefined,
|
||||
statusLoading: boolean,
|
||||
statusError: boolean
|
||||
}> = ({
|
||||
status,
|
||||
statusLoading,
|
||||
statusError
|
||||
}) => {
|
||||
|
||||
if (statusLoading || !status) {
|
||||
return (
|
||||
<Skeleton height={"4rem"}/>
|
||||
);
|
||||
}
|
||||
|
||||
if (statusError) {
|
||||
return <Typography color="error">Error loading MQTT status</Typography>;
|
||||
}
|
||||
|
||||
const getIconForState = (): React.ReactElement => {
|
||||
switch (status.state) {
|
||||
case "disconnected":
|
||||
return <MQTTDisconnectedIcon sx={{fontSize: "4rem"}}/>;
|
||||
case "ready":
|
||||
return <MQTTConnectedIcon sx={{fontSize: "4rem"}}/>;
|
||||
case "init":
|
||||
return <MQTTConnectingIcon sx={{fontSize: "4rem"}}/>;
|
||||
case "lost":
|
||||
case "alert":
|
||||
return <MQTTErrorIcon sx={{fontSize: "4rem"}}/>;
|
||||
}
|
||||
};
|
||||
|
||||
const getContentForState = (): React.ReactElement => {
|
||||
switch (status.state) {
|
||||
case "disconnected":
|
||||
return (
|
||||
<Typography variant="h5">Disconnected</Typography>
|
||||
);
|
||||
case "ready":
|
||||
return (
|
||||
<Typography variant="h5">Connected</Typography>
|
||||
);
|
||||
case "init":
|
||||
return (
|
||||
<Typography variant="h5">Connecting/Reconfiguring</Typography>
|
||||
);
|
||||
case "lost":
|
||||
case "alert":
|
||||
return (
|
||||
<Typography variant="h5">Connection error</Typography>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getMessageStats = (): React.ReactElement => {
|
||||
const items = [
|
||||
{
|
||||
header: "Messages Sent",
|
||||
body: status.stats.messages.count.sent.toString()
|
||||
},
|
||||
{
|
||||
header: "Bytes Sent",
|
||||
body: convertBytesToHumans(status.stats.messages.bytes.sent)
|
||||
},
|
||||
{
|
||||
header: "Messages Received",
|
||||
body: status.stats.messages.count.received.toString()
|
||||
},
|
||||
{
|
||||
header: "Bytes Received",
|
||||
body: convertBytesToHumans(status.stats.messages.bytes.received)
|
||||
},
|
||||
];
|
||||
|
||||
return <TextInformationGrid items={items}/>;
|
||||
};
|
||||
|
||||
const getConnectionStats = (): React.ReactElement => {
|
||||
const items = [
|
||||
{
|
||||
header: "Connects",
|
||||
body: status.stats.connection.connects.toString()
|
||||
},
|
||||
{
|
||||
header: "Disconnects",
|
||||
body: status.stats.connection.disconnects.toString()
|
||||
},
|
||||
{
|
||||
header: "Reconnects",
|
||||
body: status.stats.connection.reconnects.toString()
|
||||
},
|
||||
{
|
||||
header: "Errors",
|
||||
body: status.stats.connection.errors.toString()
|
||||
},
|
||||
];
|
||||
|
||||
return <TextInformationGrid items={items}/>;
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Grid container alignItems="center" direction="column" style={{paddingBottom: "1rem"}}>
|
||||
<Grid item style={{marginTop: "1rem"}}>
|
||||
{getIconForState()}
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
maxWidth: "100% !important", //Why, MUI? Why?
|
||||
wordWrap: "break-word",
|
||||
textAlign: "center",
|
||||
userSelect: "none"
|
||||
}}
|
||||
>
|
||||
{getContentForState()}
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
container
|
||||
direction="row"
|
||||
style={{marginTop: "1rem"}}
|
||||
>
|
||||
<Grid
|
||||
item
|
||||
style={{flexGrow: 1}}
|
||||
p={1}
|
||||
>
|
||||
<Card
|
||||
sx={{boxShadow: 3}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Message Statistics
|
||||
</Typography>
|
||||
<Divider/>
|
||||
{getMessageStats()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
style={{flexGrow: 1}}
|
||||
p={1}
|
||||
>
|
||||
<Card
|
||||
sx={{boxShadow: 3}}
|
||||
>
|
||||
<CardContent>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Connection Statistics
|
||||
</Typography>
|
||||
<Divider/>
|
||||
{getConnectionStats()}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
const GroupBox = (props: {
|
||||
title: string,
|
||||
children: React.ReactNode,
|
||||
checked?: boolean,
|
||||
disabled?: boolean,
|
||||
onChange?: ((event: React.ChangeEvent<HTMLInputElement>) => void)
|
||||
}): React.ReactElement => {
|
||||
let title = (
|
||||
<Typography
|
||||
variant="subtitle1"
|
||||
sx={{
|
||||
marginBottom: 0,
|
||||
userSelect: "none"
|
||||
}}
|
||||
>
|
||||
{props.title}
|
||||
</Typography>
|
||||
);
|
||||
if (props.onChange) {
|
||||
title = (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={props.checked}
|
||||
disabled={props.disabled}
|
||||
onChange={props.onChange}
|
||||
/>
|
||||
}
|
||||
disableTypography
|
||||
label={title}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container sx={{m: 0.2}}>
|
||||
{title}
|
||||
<Collapse in={props.checked || !props.onChange} appear={false}>
|
||||
<div>
|
||||
{props.children}
|
||||
</div>
|
||||
</Collapse>
|
||||
<Box pt={1}/>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const MQTTInput: React.FunctionComponent<{
|
||||
mqttConfiguration: MQTTConfiguration,
|
||||
modifyMQTTConfig: (value: any, configPath: Array<string>) => void,
|
||||
disabled?: boolean,
|
||||
|
||||
title: string,
|
||||
helperText: string,
|
||||
required: boolean,
|
||||
configPath: Array<string>,
|
||||
additionalProps?: InputProps
|
||||
inputPostProcessor?: (value: any) => any
|
||||
}> = ({
|
||||
mqttConfiguration,
|
||||
modifyMQTTConfig,
|
||||
disabled = false,
|
||||
|
||||
title,
|
||||
helperText,
|
||||
required,
|
||||
configPath,
|
||||
additionalProps,
|
||||
inputPostProcessor
|
||||
}) => {
|
||||
const idBase = "mqtt-config-" + configPath.join("-");
|
||||
const inputId = idBase + "-input";
|
||||
const helperId = idBase + "-helper";
|
||||
const value = getIn(mqttConfiguration, configPath);
|
||||
const error = required && !value;
|
||||
|
||||
return (
|
||||
<FormControl
|
||||
required={required}
|
||||
error={error}
|
||||
component="fieldset"
|
||||
sx={{ml: 1, mt: 2}}
|
||||
|
||||
>
|
||||
<InputLabel htmlFor={inputId}>{title}</InputLabel>
|
||||
<Input
|
||||
id={inputId}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
let newValue = additionalProps?.type === "number" ? parseInt(e.target.value) : e.target.value;
|
||||
if (inputPostProcessor) {
|
||||
newValue = inputPostProcessor(newValue);
|
||||
}
|
||||
|
||||
modifyMQTTConfig(newValue, configPath);
|
||||
}}
|
||||
aria-describedby={helperId}
|
||||
|
||||
{...additionalProps}
|
||||
/>
|
||||
<FormHelperText id={helperId} sx={{userSelect: "none"}}>
|
||||
{helperText}
|
||||
</FormHelperText>
|
||||
</FormControl>
|
||||
);
|
||||
};
|
||||
|
||||
const MQTTSwitch: React.FunctionComponent<{
|
||||
mqttConfiguration: MQTTConfiguration,
|
||||
modifyMQTTConfig: (value: any, configPath: Array<string>) => void,
|
||||
disabled?: boolean,
|
||||
|
||||
title: string,
|
||||
configPath: Array<string>,
|
||||
}> = ({
|
||||
mqttConfiguration,
|
||||
modifyMQTTConfig,
|
||||
disabled = false,
|
||||
|
||||
title,
|
||||
configPath,
|
||||
}) => {
|
||||
const value = getIn(mqttConfiguration, configPath);
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch checked={value} onChange={(e) => {
|
||||
modifyMQTTConfig(e.target.checked, configPath);
|
||||
}}/>
|
||||
}
|
||||
|
||||
label={title}
|
||||
sx={{userSelect: "none"}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const MQTTOptionalExposedCapabilitiesEditor: React.FunctionComponent<{
|
||||
mqttConfiguration: MQTTConfiguration,
|
||||
modifyMQTTConfig: (value: any, configPath: Array<string>) => void,
|
||||
disabled?: boolean,
|
||||
|
||||
configPath: Array<string>,
|
||||
exposableCapabilities: Array<string>
|
||||
}> = ({
|
||||
mqttConfiguration,
|
||||
modifyMQTTConfig,
|
||||
disabled = false,
|
||||
|
||||
configPath,
|
||||
exposableCapabilities
|
||||
}) => {
|
||||
let selection: Array<string> = getIn(mqttConfiguration, configPath);
|
||||
|
||||
return (
|
||||
<Container sx={{m: 0.2}}>
|
||||
<FormGroup>
|
||||
{
|
||||
exposableCapabilities.map((capabilityName: string) => {
|
||||
return (
|
||||
<FormControlLabel
|
||||
key={capabilityName}
|
||||
control={
|
||||
<Checkbox
|
||||
checked={selection.includes(capabilityName)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
selection.push(capabilityName);
|
||||
} else {
|
||||
selection = selection.filter(e => {
|
||||
return e !== capabilityName;
|
||||
});
|
||||
}
|
||||
|
||||
modifyMQTTConfig(selection, configPath);
|
||||
}
|
||||
}
|
||||
/>
|
||||
}
|
||||
|
||||
label={capabilityName}
|
||||
sx={{userSelect: "none"}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
</FormGroup>
|
||||
</Container>
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeStringForMQTT = (value: string, allowSlashes = false) => {
|
||||
/*
|
||||
This rather limited set of characters is unfortunately required by Home Assistant
|
||||
Without Home Assistant, it would be enough to replace [\s+#/]
|
||||
|
||||
See also: https://www.home-assistant.io/docs/mqtt/discovery/#discovery-topic
|
||||
*/
|
||||
return value.replace(
|
||||
allowSlashes ? /[^a-zA-Z0-9_\-/]/g : /[^a-zA-Z0-9_-]/g,
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeTopicPrefix = (value: string) => {
|
||||
return value.replace(
|
||||
/^\//,
|
||||
""
|
||||
).replace(
|
||||
/\/$/,
|
||||
""
|
||||
);
|
||||
};
|
||||
|
||||
const sanitizeConfigBeforeSaving = (mqttConfiguration: MQTTConfiguration) => {
|
||||
mqttConfiguration.customizations.topicPrefix = sanitizeTopicPrefix(mqttConfiguration.customizations.topicPrefix);
|
||||
};
|
||||
|
||||
const MQTTConnectivity = (): React.ReactElement => {
|
||||
const theme = useTheme();
|
||||
|
||||
const [anchorElement, setAnchorElement] = React.useState(null);
|
||||
|
||||
const identifierElement = React.useRef(null);
|
||||
const topicElement = React.useRef(null);
|
||||
|
||||
const {
|
||||
data: storedMQTTConfiguration,
|
||||
isPending: mqttConfigurationPending,
|
||||
isError: mqttConfigurationError,
|
||||
} = useMQTTConfigurationQuery();
|
||||
|
||||
const {
|
||||
data: mqttStatus,
|
||||
isPending: mqttStatusPending,
|
||||
isError: mqttStatusError,
|
||||
} = useMQTTStatusQuery();
|
||||
|
||||
const {
|
||||
data: mqttProperties,
|
||||
isPending: mqttPropertiesPending,
|
||||
isError: mqttPropertiesError
|
||||
} = useMQTTPropertiesQuery();
|
||||
|
||||
const {mutate: updateMQTTConfiguration, isPending: mqttConfigurationUpdating} = useMQTTConfigurationMutation();
|
||||
|
||||
const [mqttConfiguration, setMQTTConfiguration] = React.useState<MQTTConfiguration | null>(null);
|
||||
const [configurationModified, setConfigurationModified] = React.useState<boolean>(false);
|
||||
|
||||
|
||||
const [showMQTTAuthPasswordAsPlain, setShowMQTTAuthPasswordAsPlain] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (storedMQTTConfiguration && !configurationModified && !mqttConfigurationUpdating) {
|
||||
setMQTTConfiguration(deepCopy(storedMQTTConfiguration));
|
||||
setConfigurationModified(false);
|
||||
}
|
||||
}, [storedMQTTConfiguration, configurationModified, mqttConfigurationUpdating]);
|
||||
|
||||
const modifyMQTTConfig = React.useCallback((value: any, configPath: Array<string>): void => {
|
||||
if (!mqttConfiguration) {
|
||||
return;
|
||||
}
|
||||
const newConfig = deepCopy(mqttConfiguration);
|
||||
setIn(newConfig, value, configPath);
|
||||
setMQTTConfiguration(newConfig);
|
||||
setConfigurationModified(true);
|
||||
}, [mqttConfiguration]);
|
||||
|
||||
if (mqttConfigurationPending || mqttPropertiesPending || !mqttConfiguration) {
|
||||
return (
|
||||
<>
|
||||
<Skeleton height={"12rem"}/>
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
<Skeleton height={"36rem"}/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
if (mqttConfigurationError || mqttPropertiesError || !storedMQTTConfiguration || !mqttProperties) {
|
||||
return <Typography color="error">Error loading MQTT configuration</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<MQTTStatusComponent
|
||||
status={mqttStatus}
|
||||
statusLoading={mqttStatusPending}
|
||||
statusError={mqttStatusError}
|
||||
/>
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={mqttConfiguration.enabled}
|
||||
onChange={e => {
|
||||
modifyMQTTConfig(e.target.checked, ["enabled"]);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="MQTT enabled"
|
||||
sx={{userSelect: "none", marginLeft: "0.5rem", marginBottom: "0.5rem"}}
|
||||
/>
|
||||
|
||||
<GroupBox title="Connection">
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Host"
|
||||
helperText="The MQTT Broker hostname"
|
||||
required={true}
|
||||
configPath={["connection", "host"]}
|
||||
inputPostProcessor={(value) => {
|
||||
return extractHostFromUrl(value);
|
||||
}}
|
||||
/>
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Port"
|
||||
helperText="The MQTT Broker port"
|
||||
required={true}
|
||||
configPath={["connection", "port"]}
|
||||
additionalProps={{type: "number"}}
|
||||
/>
|
||||
|
||||
<GroupBox title="TLS" checked={mqttConfiguration.connection.tls.enabled}
|
||||
onChange={(e) => {
|
||||
modifyMQTTConfig(e.target.checked, ["connection", "tls", "enabled"]);
|
||||
}}>
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="CA"
|
||||
helperText="The optional Certificate Authority to verify the connection with"
|
||||
required={false}
|
||||
configPath={["connection", "tls", "ca"]}
|
||||
additionalProps={{
|
||||
multiline: true,
|
||||
minRows: 3,
|
||||
maxRows: 10,
|
||||
}}
|
||||
/>
|
||||
<br/><br/>
|
||||
<MQTTSwitch
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
title="Ignore certificate errors"
|
||||
configPath={["connection", "tls", "ignoreCertificateErrors"]}
|
||||
/>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox title="Authentication">
|
||||
<GroupBox title="Credentials"
|
||||
checked={mqttConfiguration.connection.authentication.credentials.enabled}
|
||||
onChange={(e) => {
|
||||
modifyMQTTConfig(e.target.checked, ["connection", "authentication", "credentials", "enabled"]);
|
||||
}}>
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Username"
|
||||
helperText="Username for authentication"
|
||||
required={true}
|
||||
configPath={["connection", "authentication", "credentials", "username"]}
|
||||
/>
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Password"
|
||||
helperText="Password for authentication"
|
||||
required={false}
|
||||
configPath={["connection", "authentication", "credentials", "password"]}
|
||||
additionalProps={{
|
||||
type: showMQTTAuthPasswordAsPlain ? "text" : "password",
|
||||
endAdornment: (
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={() => {
|
||||
setShowMQTTAuthPasswordAsPlain(!showMQTTAuthPasswordAsPlain);
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
edge="end"
|
||||
>
|
||||
{showMQTTAuthPasswordAsPlain ? <VisibilityOffIcon/> : <VisibilityIcon/>}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</GroupBox>
|
||||
<GroupBox title="Client certificate"
|
||||
checked={mqttConfiguration.connection.authentication.clientCertificate.enabled}
|
||||
onChange={(e) => {
|
||||
modifyMQTTConfig(e.target.checked, ["connection", "authentication", "clientCertificate", "enabled"]);
|
||||
}}>
|
||||
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Certificate"
|
||||
helperText="The full certificate as a multi-line string"
|
||||
required={true}
|
||||
configPath={["connection", "authentication", "clientCertificate", "certificate"]}
|
||||
additionalProps={{
|
||||
multiline: true,
|
||||
minRows: 3,
|
||||
maxRows: 10
|
||||
}}
|
||||
/>
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Key"
|
||||
helperText="The full key as a multi-line string"
|
||||
required={true}
|
||||
configPath={["connection", "authentication", "clientCertificate", "key"]}
|
||||
additionalProps={{
|
||||
multiline: true,
|
||||
minRows: 3,
|
||||
maxRows: 10
|
||||
}}
|
||||
/>
|
||||
</GroupBox>
|
||||
</GroupBox>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox title="Customizations">
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Topic prefix"
|
||||
helperText="MQTT topic prefix"
|
||||
required={false}
|
||||
configPath={["customizations", "topicPrefix"]}
|
||||
additionalProps={{
|
||||
placeholder: mqttProperties.defaults.customizations.topicPrefix,
|
||||
color: "warning",
|
||||
onFocus: () => {
|
||||
setAnchorElement(topicElement.current);
|
||||
},
|
||||
onBlur: () => {
|
||||
setAnchorElement(null);
|
||||
},
|
||||
}}
|
||||
inputPostProcessor={(value) => {
|
||||
return sanitizeStringForMQTT(
|
||||
value,
|
||||
true
|
||||
).replace(
|
||||
/\/\//g,
|
||||
"/"
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<MQTTInput
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
|
||||
title="Identifier"
|
||||
helperText="The machine-readable name of the robot"
|
||||
required={false}
|
||||
configPath={["identity", "identifier"]}
|
||||
additionalProps={{
|
||||
placeholder: mqttProperties.defaults.identity.identifier,
|
||||
color: "secondary",
|
||||
onFocus: () => {
|
||||
setAnchorElement(identifierElement.current);
|
||||
},
|
||||
onBlur: () => {
|
||||
setAnchorElement(null);
|
||||
},
|
||||
}}
|
||||
inputPostProcessor={(value) => {
|
||||
return sanitizeStringForMQTT(value, false);
|
||||
}}
|
||||
/>
|
||||
<br/>
|
||||
<Typography variant="subtitle2" sx={{mt: "0.5rem", mb: "2rem", userSelect: "none"}} noWrap={false}>
|
||||
The MQTT Topic structure will look like this:<br/>
|
||||
<span style={{
|
||||
fontFamily: "\"JetBrains Mono\",monospace",
|
||||
fontWeight: 200,
|
||||
overflowWrap: "anywhere",
|
||||
userSelect: "text"
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
color: theme.palette.warning.main
|
||||
}}
|
||||
ref={topicElement}
|
||||
>
|
||||
{sanitizeTopicPrefix(mqttConfiguration.customizations.topicPrefix) || mqttProperties.defaults.customizations.topicPrefix}
|
||||
</span>
|
||||
/<wbr/>
|
||||
<span
|
||||
style={{
|
||||
color: theme.palette.secondary.main
|
||||
}}
|
||||
ref={identifierElement}
|
||||
>
|
||||
{mqttConfiguration.identity.identifier || mqttProperties.defaults.identity.identifier}
|
||||
</span>
|
||||
/<wbr/>BatteryStateAttribute/<wbr/>level
|
||||
</span>
|
||||
</Typography>
|
||||
<MQTTSwitch
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
title="Provide map data"
|
||||
configPath={["customizations", "provideMapData"]}
|
||||
/>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox title="Interfaces">
|
||||
<GroupBox title="Homie" checked={mqttConfiguration.interfaces.homie.enabled}
|
||||
onChange={(e) => {
|
||||
modifyMQTTConfig(e.target.checked, ["interfaces", "homie", "enabled"]);
|
||||
}}>
|
||||
<FormControl component="fieldset" variant="standard">
|
||||
<FormLabel component="legend" sx={{userSelect: "none"}}>Select the options for Homie
|
||||
integration</FormLabel>
|
||||
<FormGroup>
|
||||
<MQTTSwitch
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
title={"Provide autodiscovery for \"I Can't Believe It's Not Valetudo\" map"}
|
||||
configPath={["interfaces", "homie", "addICBINVMapProperty"]}
|
||||
/>
|
||||
<MQTTSwitch
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
title="Delete autodiscovery on shutdown"
|
||||
configPath={["interfaces", "homie", "cleanAttributesOnShutdown"]}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
</GroupBox>
|
||||
|
||||
<GroupBox title="Home Assistant" checked={mqttConfiguration.interfaces.homeassistant.enabled}
|
||||
onChange={(e) => {
|
||||
modifyMQTTConfig(e.target.checked, ["interfaces", "homeassistant", "enabled"]);
|
||||
}}>
|
||||
<FormControl component="fieldset" variant="standard">
|
||||
<FormLabel component="legend" sx={{userSelect: "none"}}>Select the options for Home Assistant
|
||||
integration</FormLabel>
|
||||
<FormGroup>
|
||||
<MQTTSwitch
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
title="Delete autodiscovery on shutdown"
|
||||
configPath={["interfaces", "homeassistant", "cleanAutoconfOnShutdown"]}
|
||||
/>
|
||||
</FormGroup>
|
||||
</FormControl>
|
||||
</GroupBox>
|
||||
</GroupBox>
|
||||
|
||||
{
|
||||
mqttProperties.optionalExposableCapabilities.length > 0 &&
|
||||
<GroupBox title="Optional exposable capabilities">
|
||||
<MQTTOptionalExposedCapabilitiesEditor
|
||||
mqttConfiguration={mqttConfiguration}
|
||||
modifyMQTTConfig={modifyMQTTConfig}
|
||||
configPath={["optionalExposedCapabilities"]}
|
||||
exposableCapabilities={mqttProperties.optionalExposableCapabilities}
|
||||
/>
|
||||
</GroupBox>
|
||||
}
|
||||
|
||||
<Popper
|
||||
open={Boolean(anchorElement)}
|
||||
anchorEl={anchorElement}
|
||||
>
|
||||
<Box>
|
||||
<ArrowUpward fontSize={"large"} color={"info"}/>
|
||||
</Box>
|
||||
</Popper>
|
||||
|
||||
<InfoBox
|
||||
boxShadow={5}
|
||||
style={{
|
||||
marginTop: "2rem",
|
||||
marginBottom: "2rem"
|
||||
}}
|
||||
>
|
||||
<Typography color="info">
|
||||
Valetudo recommends the use of the Eclipse Mosquitto MQTT Broker, which is FOSS, has a
|
||||
tiny resource footprint and is part of basically every GNU/Linux distribution.
|
||||
You can also install it as a container via the container management solution of your choice.
|
||||
|
||||
<br/><br/>
|
||||
If you're experiencing problems regarding MQTT, make sure to try Mosquitto since some other
|
||||
MQTT
|
||||
brokers only implement a subset of the MQTT spec, which often leads to issues when used with
|
||||
Valetudo.
|
||||
|
||||
<br/><br/>
|
||||
If you're using Mosquitto but still experience issues, make sure that your ACLs (if any) are
|
||||
correct and
|
||||
you're also using the correct login credentials for those.
|
||||
Valetudo will not receive any feedback from the broker if publishing fails due to ACL restrictions
|
||||
as such feedback
|
||||
simply isn't part of the MQTT v3.1.1 spec. MQTT v5 fixes this issue but isn't widely
|
||||
available just yet.
|
||||
</Typography>
|
||||
</InfoBox>
|
||||
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
|
||||
<Grid container>
|
||||
<Grid item style={{marginLeft: "auto"}}>
|
||||
<LoadingButton
|
||||
disabled={!configurationModified}
|
||||
loading={mqttConfigurationUpdating}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
onClick={() => {
|
||||
sanitizeConfigBeforeSaving(mqttConfiguration);
|
||||
|
||||
updateMQTTConfiguration(mqttConfiguration);
|
||||
setConfigurationModified(false);
|
||||
}}
|
||||
>
|
||||
Save configuration
|
||||
</LoadingButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const MQTTConnectivityPage = (): React.ReactElement => {
|
||||
const {
|
||||
isFetching: mqttStatusFetching,
|
||||
refetch: refetchMqttStatus,
|
||||
} = useMQTTStatusQuery();
|
||||
|
||||
return (
|
||||
<PaperContainer>
|
||||
<Grid container direction="row">
|
||||
<Box style={{width: "100%"}}>
|
||||
<DetailPageHeaderRow
|
||||
title="MQTT Connectivity"
|
||||
icon={<MQTTIcon/>}
|
||||
onRefreshClick={() => {
|
||||
refetchMqttStatus().catch(() => {
|
||||
/* intentional */
|
||||
});
|
||||
}}
|
||||
isRefreshing={mqttStatusFetching}
|
||||
/>
|
||||
<MQTTConnectivity/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</PaperContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default MQTTConnectivityPage;
|
||||
@ -1,305 +0,0 @@
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import {
|
||||
NTPClientStatus,
|
||||
useNTPClientConfigurationMutation,
|
||||
useNTPClientConfigurationQuery,
|
||||
useNTPClientStatusQuery
|
||||
} from "../../api";
|
||||
import LoadingFade from "../../components/LoadingFade";
|
||||
import {LoadingButton} from "@mui/lab";
|
||||
|
||||
import {
|
||||
AccessTime as NTPIcon,
|
||||
Sync as SyncEnabledIcon,
|
||||
SyncDisabled as SyncDisabledIcon,
|
||||
SyncLock as SyncSuccessfulIcon,
|
||||
SyncProblem as SyncErrorIcon
|
||||
} from "@mui/icons-material";
|
||||
import InfoBox from "../../components/InfoBox";
|
||||
import PaperContainer from "../../components/PaperContainer";
|
||||
import DetailPageHeaderRow from "../../components/DetailPageHeaderRow";
|
||||
import {extractHostFromUrl} from "../../utils";
|
||||
|
||||
const NTPClientStatusComponent : React.FunctionComponent<{ status: NTPClientStatus | undefined, statusLoading: boolean, stateError: boolean }> = ({
|
||||
status,
|
||||
statusLoading,
|
||||
stateError
|
||||
}) => {
|
||||
if (statusLoading || !status) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
);
|
||||
}
|
||||
|
||||
if (stateError) {
|
||||
return <Typography color="error">Error loading NTPClient state</Typography>;
|
||||
}
|
||||
|
||||
const getIconForState = () : React.ReactElement => {
|
||||
switch (status.state.__class) {
|
||||
case "ValetudoNTPClientEnabledState":
|
||||
return <SyncEnabledIcon sx={{ fontSize: "4rem" }}/>;
|
||||
case "ValetudoNTPClientDisabledState":
|
||||
return <SyncDisabledIcon sx={{ fontSize: "4rem" }}/>;
|
||||
case "ValetudoNTPClientSyncedState":
|
||||
return <SyncSuccessfulIcon sx={{ fontSize: "4rem" }}/>;
|
||||
case "ValetudoNTPClientErrorState":
|
||||
return <SyncErrorIcon sx={{ fontSize: "4rem" }}/>;
|
||||
}
|
||||
};
|
||||
|
||||
const getContentForState = () : React.ReactElement | undefined => {
|
||||
switch (status.state.__class) {
|
||||
case "ValetudoNTPClientErrorState":
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h5" color="red">Error: {status.state.type}</Typography>
|
||||
<Typography color="red">{status.state.message}</Typography>
|
||||
</>
|
||||
);
|
||||
case "ValetudoNTPClientEnabledState":
|
||||
return (
|
||||
<Typography variant="h5">Time sync enabled</Typography>
|
||||
);
|
||||
case "ValetudoNTPClientDisabledState":
|
||||
return (
|
||||
<Typography variant="h5">Time sync disabled</Typography>
|
||||
);
|
||||
case "ValetudoNTPClientSyncedState":
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h5">Time sync successful</Typography>
|
||||
<Typography>Offset: {status.state.offset} ms</Typography>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Grid container alignItems="center" direction="column" style={{paddingBottom:"1rem"}}>
|
||||
<Grid item style={{marginTop:"1rem"}}>
|
||||
{getIconForState()}
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
maxWidth: "100% !important", //Why, MUI? Why?
|
||||
wordWrap: "break-word",
|
||||
textAlign: "center",
|
||||
userSelect: "none"
|
||||
}}
|
||||
>
|
||||
{getContentForState()}
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
maxWidth: "100% !important", //Why, MUI? Why?
|
||||
wordWrap: "break-word",
|
||||
textAlign: "center",
|
||||
userSelect: "none",
|
||||
marginTop: "0.5rem"
|
||||
}}
|
||||
>
|
||||
Current robot time: {status.robotTime}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const NTPConnectivity = (): React.ReactElement => {
|
||||
const {
|
||||
data: ntpClientStatus,
|
||||
isPending: ntpClientStatusPending,
|
||||
isFetching: ntpClientStatusFetching,
|
||||
isError: ntpClientStatusError,
|
||||
refetch: refetchNtpClientState
|
||||
} = useNTPClientStatusQuery();
|
||||
|
||||
const {
|
||||
data: ntpClientConfig,
|
||||
isPending: ntpClientConfigPending,
|
||||
isError: ntpClientConfigError,
|
||||
} = useNTPClientConfigurationQuery();
|
||||
|
||||
const {mutate: updateConfiguration, isPending: configurationUpdating} = useNTPClientConfigurationMutation();
|
||||
|
||||
const [enabled, setEnabled] = React.useState(false);
|
||||
const [server, setServer] = React.useState("");
|
||||
const [port, setPort] = React.useState(0);
|
||||
const [ntpInterval, setNtpInterval] = React.useState(0);
|
||||
const [ntpTimeout, setNtpTimeout] = React.useState(0);
|
||||
|
||||
const [configurationModified, setConfigurationModified] = React.useState<boolean>(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ntpClientConfig) {
|
||||
setEnabled(ntpClientConfig.enabled);
|
||||
setServer(ntpClientConfig.server);
|
||||
setPort(ntpClientConfig.port);
|
||||
setNtpInterval(ntpClientConfig.interval);
|
||||
setNtpTimeout(ntpClientConfig.timeout);
|
||||
}
|
||||
}, [ntpClientConfig]);
|
||||
|
||||
if (ntpClientStatusPending || ntpClientConfigPending) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
);
|
||||
}
|
||||
|
||||
if (ntpClientStatusError || ntpClientConfigError || !ntpClientStatus || !ntpClientConfig) {
|
||||
return <Typography color="error">Error loading NTP Client configuration</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PaperContainer>
|
||||
<Grid container direction="row">
|
||||
<Box style={{width: "100%"}}>
|
||||
<DetailPageHeaderRow
|
||||
title="NTP Connectivity"
|
||||
icon={<NTPIcon/>}
|
||||
onRefreshClick={() => {
|
||||
refetchNtpClientState().catch(() => {
|
||||
/* intentional */
|
||||
});
|
||||
}}
|
||||
isRefreshing={ntpClientStatusFetching}
|
||||
/>
|
||||
|
||||
<NTPClientStatusComponent
|
||||
status={ntpClientStatus}
|
||||
statusLoading={ntpClientStatusPending}
|
||||
stateError={ntpClientStatusError}
|
||||
/>
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onChange={e => {
|
||||
setEnabled(e.target.checked);
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="NTP enabled"
|
||||
sx={{mb: 1}}
|
||||
/>
|
||||
<Grid container spacing={1} sx={{mb: 2}}>
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width:"100%"}}
|
||||
label="Server"
|
||||
value={server}
|
||||
disabled={!enabled}
|
||||
variant="standard"
|
||||
onChange={e => {
|
||||
setServer(extractHostFromUrl(e.target.value));
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width:"100%"}}
|
||||
label="Port"
|
||||
value={port}
|
||||
disabled={!enabled}
|
||||
type="number"
|
||||
inputProps={{min: 1, max: 65535}}
|
||||
variant="standard"
|
||||
onChange={e => {
|
||||
setPort(parseInt(e.target.value));
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width:"100%"}}
|
||||
label="Interval (hours)"
|
||||
value={ntpInterval / 3_600_000}
|
||||
sx={{minWidth: 100}}
|
||||
disabled={!enabled}
|
||||
type="number"
|
||||
inputProps={{min: 1, max: 24}}
|
||||
variant="standard"
|
||||
onChange={e => {
|
||||
setNtpInterval(3_600_000 * parseInt(e.target.value));
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width:"100%"}}
|
||||
label="Timeout (seconds)"
|
||||
value={ntpTimeout / 1000}
|
||||
sx={{minWidth: 150}}
|
||||
disabled={!enabled}
|
||||
type="number"
|
||||
inputProps={{min: 5, max: 60}}
|
||||
variant="standard"
|
||||
onChange={e => {
|
||||
setNtpTimeout(1000 * parseInt(e.target.value));
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<InfoBox
|
||||
boxShadow={5}
|
||||
style={{
|
||||
marginTop: "3rem",
|
||||
marginBottom: "2rem"
|
||||
}}
|
||||
>
|
||||
<Typography color="info">
|
||||
Valetudo needs a synchronized clock for timers to work and the log timestamps to make sense.
|
||||
Furthermore, the integrated updater may not work if the clock is set wrongly due to TLS
|
||||
certificates usually only being valid within a particular period of time.
|
||||
</Typography>
|
||||
</InfoBox>
|
||||
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
<Grid container>
|
||||
<Grid item style={{marginLeft: "auto"}}>
|
||||
<LoadingButton
|
||||
loading={configurationUpdating}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
disabled={!configurationModified}
|
||||
onClick={() => {
|
||||
updateConfiguration({
|
||||
enabled: enabled,
|
||||
server: server,
|
||||
port: port,
|
||||
interval: ntpInterval,
|
||||
timeout: ntpTimeout
|
||||
});
|
||||
setConfigurationModified(false);
|
||||
}}
|
||||
>
|
||||
Save configuration
|
||||
</LoadingButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Grid>
|
||||
</PaperContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default NTPConnectivity;
|
||||
320
frontend/src/options/connectivity/NTPConnectivityPage.tsx
Normal file
320
frontend/src/options/connectivity/NTPConnectivityPage.tsx
Normal file
@ -0,0 +1,320 @@
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Skeleton,
|
||||
TextField,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import {
|
||||
NTPClientStatus,
|
||||
useNTPClientConfigurationMutation,
|
||||
useNTPClientConfigurationQuery,
|
||||
useNTPClientStatusQuery
|
||||
} from "../../api";
|
||||
import {LoadingButton} from "@mui/lab";
|
||||
|
||||
import {
|
||||
AccessTime as NTPIcon,
|
||||
Sync as SyncEnabledIcon,
|
||||
SyncDisabled as SyncDisabledIcon,
|
||||
SyncLock as SyncSuccessfulIcon,
|
||||
SyncProblem as SyncErrorIcon
|
||||
} from "@mui/icons-material";
|
||||
import InfoBox from "../../components/InfoBox";
|
||||
import PaperContainer from "../../components/PaperContainer";
|
||||
import DetailPageHeaderRow from "../../components/DetailPageHeaderRow";
|
||||
import {extractHostFromUrl} from "../../utils";
|
||||
|
||||
const NTPClientStatusComponent: React.FunctionComponent<{
|
||||
status: NTPClientStatus | undefined,
|
||||
statusLoading: boolean,
|
||||
stateError: boolean
|
||||
}> = ({
|
||||
status,
|
||||
statusLoading,
|
||||
stateError
|
||||
}) => {
|
||||
if (statusLoading || !status) {
|
||||
return (
|
||||
<Skeleton height={"8rem"}/>
|
||||
);
|
||||
}
|
||||
|
||||
if (stateError) {
|
||||
return <Typography color="error">Error loading NTPClient state</Typography>;
|
||||
}
|
||||
|
||||
const getIconForState = (): React.ReactElement => {
|
||||
switch (status.state.__class) {
|
||||
case "ValetudoNTPClientEnabledState":
|
||||
return <SyncEnabledIcon sx={{fontSize: "4rem"}}/>;
|
||||
case "ValetudoNTPClientDisabledState":
|
||||
return <SyncDisabledIcon sx={{fontSize: "4rem"}}/>;
|
||||
case "ValetudoNTPClientSyncedState":
|
||||
return <SyncSuccessfulIcon sx={{fontSize: "4rem"}}/>;
|
||||
case "ValetudoNTPClientErrorState":
|
||||
return <SyncErrorIcon sx={{fontSize: "4rem"}}/>;
|
||||
}
|
||||
};
|
||||
|
||||
const getContentForState = (): React.ReactElement | undefined => {
|
||||
switch (status.state.__class) {
|
||||
case "ValetudoNTPClientErrorState":
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h5" color="red">Error: {status.state.type}</Typography>
|
||||
<Typography color="red">{status.state.message}</Typography>
|
||||
</>
|
||||
);
|
||||
case "ValetudoNTPClientEnabledState":
|
||||
return (
|
||||
<Typography variant="h5">Time sync enabled</Typography>
|
||||
);
|
||||
case "ValetudoNTPClientDisabledState":
|
||||
return (
|
||||
<Typography variant="h5">Time sync disabled</Typography>
|
||||
);
|
||||
case "ValetudoNTPClientSyncedState":
|
||||
return (
|
||||
<>
|
||||
<Typography variant="h5">Time sync successful</Typography>
|
||||
<Typography>Offset: {status.state.offset} ms</Typography>
|
||||
</>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<Grid container alignItems="center" direction="column" style={{paddingBottom: "1rem"}}>
|
||||
<Grid item style={{marginTop: "1rem"}}>
|
||||
{getIconForState()}
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
maxWidth: "100% !important", //Why, MUI? Why?
|
||||
wordWrap: "break-word",
|
||||
textAlign: "center",
|
||||
userSelect: "none"
|
||||
}}
|
||||
>
|
||||
{getContentForState()}
|
||||
</Grid>
|
||||
<Grid
|
||||
item
|
||||
sx={{
|
||||
maxWidth: "100% !important", //Why, MUI? Why?
|
||||
wordWrap: "break-word",
|
||||
textAlign: "center",
|
||||
userSelect: "none",
|
||||
marginTop: "0.5rem"
|
||||
}}
|
||||
>
|
||||
Current robot time: {status.robotTime}
|
||||
</Grid>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const NTPConnectivity = (): React.ReactElement => {
|
||||
const {
|
||||
data: ntpClientStatus,
|
||||
isPending: ntpClientStatusPending,
|
||||
isError: ntpClientStatusError,
|
||||
} = useNTPClientStatusQuery();
|
||||
|
||||
const {
|
||||
data: ntpClientConfig,
|
||||
isPending: ntpClientConfigPending,
|
||||
isError: ntpClientConfigError,
|
||||
} = useNTPClientConfigurationQuery();
|
||||
|
||||
const {mutate: updateConfiguration, isPending: configurationUpdating} = useNTPClientConfigurationMutation();
|
||||
|
||||
const [enabled, setEnabled] = React.useState(false);
|
||||
const [server, setServer] = React.useState("");
|
||||
const [port, setPort] = React.useState(0);
|
||||
const [ntpInterval, setNtpInterval] = React.useState(0);
|
||||
const [ntpTimeout, setNtpTimeout] = React.useState(0);
|
||||
|
||||
const [configurationModified, setConfigurationModified] = React.useState<boolean>(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ntpClientConfig) {
|
||||
setEnabled(ntpClientConfig.enabled);
|
||||
setServer(ntpClientConfig.server);
|
||||
setPort(ntpClientConfig.port);
|
||||
setNtpInterval(ntpClientConfig.interval);
|
||||
setNtpTimeout(ntpClientConfig.timeout);
|
||||
}
|
||||
}, [ntpClientConfig]);
|
||||
|
||||
if (ntpClientStatusPending || ntpClientConfigPending) {
|
||||
return (
|
||||
<Skeleton height={"8rem"}/>
|
||||
);
|
||||
}
|
||||
|
||||
if (ntpClientStatusError || ntpClientConfigError || !ntpClientStatus || !ntpClientConfig) {
|
||||
return <Typography color="error">Error loading NTP Client configuration</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<NTPClientStatusComponent
|
||||
status={ntpClientStatus}
|
||||
statusLoading={ntpClientStatusPending}
|
||||
stateError={ntpClientStatusError}
|
||||
/>
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onChange={e => {
|
||||
setEnabled(e.target.checked);
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="NTP enabled"
|
||||
sx={{mb: 1}}
|
||||
/>
|
||||
<Grid container spacing={1} sx={{mb: 2}}>
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width: "100%"}}
|
||||
label="Server"
|
||||
value={server}
|
||||
disabled={!enabled}
|
||||
variant="standard"
|
||||
onChange={e => {
|
||||
setServer(extractHostFromUrl(e.target.value));
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width: "100%"}}
|
||||
label="Port"
|
||||
value={port}
|
||||
disabled={!enabled}
|
||||
type="number"
|
||||
inputProps={{min: 1, max: 65535}}
|
||||
variant="standard"
|
||||
onChange={e => {
|
||||
setPort(parseInt(e.target.value));
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width: "100%"}}
|
||||
label="Interval (hours)"
|
||||
value={ntpInterval / 3_600_000}
|
||||
sx={{minWidth: 100}}
|
||||
disabled={!enabled}
|
||||
type="number"
|
||||
inputProps={{min: 1, max: 24}}
|
||||
variant="standard"
|
||||
onChange={e => {
|
||||
setNtpInterval(3_600_000 * parseInt(e.target.value));
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width: "100%"}}
|
||||
label="Timeout (seconds)"
|
||||
value={ntpTimeout / 1000}
|
||||
sx={{minWidth: 150}}
|
||||
disabled={!enabled}
|
||||
type="number"
|
||||
inputProps={{min: 5, max: 60}}
|
||||
variant="standard"
|
||||
onChange={e => {
|
||||
setNtpTimeout(1000 * parseInt(e.target.value));
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<InfoBox
|
||||
boxShadow={5}
|
||||
style={{
|
||||
marginTop: "3rem",
|
||||
marginBottom: "2rem"
|
||||
}}
|
||||
>
|
||||
<Typography color="info">
|
||||
Valetudo needs a synchronized clock for timers to work and the log timestamps to make sense.
|
||||
Furthermore, the integrated updater may not work if the clock is set wrongly due to TLS
|
||||
certificates usually only being valid within a particular period of time.
|
||||
</Typography>
|
||||
</InfoBox>
|
||||
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
<Grid container>
|
||||
<Grid item style={{marginLeft: "auto"}}>
|
||||
<LoadingButton
|
||||
loading={configurationUpdating}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
disabled={!configurationModified}
|
||||
onClick={() => {
|
||||
updateConfiguration({
|
||||
enabled: enabled,
|
||||
server: server,
|
||||
port: port,
|
||||
interval: ntpInterval,
|
||||
timeout: ntpTimeout
|
||||
});
|
||||
setConfigurationModified(false);
|
||||
}}
|
||||
>
|
||||
Save configuration
|
||||
</LoadingButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const NTPConnectivityPage = (): React.ReactElement => {
|
||||
const {
|
||||
isFetching: ntpClientStatusFetching,
|
||||
refetch: refetchNtpClientState
|
||||
} = useNTPClientStatusQuery();
|
||||
|
||||
return (
|
||||
<PaperContainer>
|
||||
<Grid container direction="row">
|
||||
<Box style={{width: "100%"}}>
|
||||
<DetailPageHeaderRow
|
||||
title="NTP Connectivity"
|
||||
icon={<NTPIcon/>}
|
||||
onRefreshClick={() => {
|
||||
refetchNtpClientState().catch(() => {
|
||||
/* intentional */
|
||||
});
|
||||
}}
|
||||
isRefreshing={ntpClientStatusFetching}
|
||||
/>
|
||||
|
||||
<NTPConnectivity/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</PaperContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default NTPConnectivityPage;
|
||||
@ -1,140 +0,0 @@
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import {
|
||||
useNetworkAdvertisementConfigurationMutation,
|
||||
useNetworkAdvertisementConfigurationQuery,
|
||||
useNetworkAdvertisementPropertiesQuery
|
||||
} from "../../api";
|
||||
import LoadingFade from "../../components/LoadingFade";
|
||||
import {LoadingButton} from "@mui/lab";
|
||||
import InfoBox from "../../components/InfoBox";
|
||||
import PaperContainer from "../../components/PaperContainer";
|
||||
import {
|
||||
AutoFixHigh as NetworkAdvertisementIcon
|
||||
} from "@mui/icons-material";
|
||||
import DetailPageHeaderRow from "../../components/DetailPageHeaderRow";
|
||||
|
||||
const NetworkAdvertisementSettings = (): React.ReactElement => {
|
||||
const {
|
||||
data: storedConfiguration,
|
||||
isPending: configurationPending,
|
||||
isError: configurationError,
|
||||
} = useNetworkAdvertisementConfigurationQuery();
|
||||
|
||||
const {
|
||||
data: properties,
|
||||
isPending: propertiesPending,
|
||||
isError: propertiesLoadError
|
||||
} = useNetworkAdvertisementPropertiesQuery();
|
||||
|
||||
const {mutate: updateConfiguration, isPending: configurationUpdating} = useNetworkAdvertisementConfigurationMutation();
|
||||
|
||||
const [enabled, setEnabled] = React.useState(false);
|
||||
|
||||
const [configurationModified, setConfigurationModified] = React.useState<boolean>(false);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
if (storedConfiguration) {
|
||||
setEnabled(storedConfiguration.enabled);
|
||||
}
|
||||
}, [storedConfiguration]);
|
||||
|
||||
if (configurationPending || propertiesPending) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
);
|
||||
}
|
||||
|
||||
if (configurationError || propertiesLoadError || !storedConfiguration) {
|
||||
return <Typography color="error">Error loading Network Advertisement configuration</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<PaperContainer>
|
||||
<Grid container direction="row">
|
||||
<Box style={{width: "100%"}}>
|
||||
<DetailPageHeaderRow
|
||||
title="Network Advertisement"
|
||||
icon={<NetworkAdvertisementIcon/>}
|
||||
/>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onChange={e => {
|
||||
setEnabled(e.target.checked);
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Network Advertisement enabled"
|
||||
sx={{mb: 1, marginTop: "1rem", userSelect: "none"}}
|
||||
/>
|
||||
<Grid container spacing={1} sx={{mb: 1, mt: "1rem"}} direction="row">
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width: "100%"}}
|
||||
label="Zeroconf Hostname"
|
||||
value={properties?.zeroconfHostname ?? ""}
|
||||
variant="standard"
|
||||
disabled={true}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<InfoBox
|
||||
boxShadow={5}
|
||||
style={{
|
||||
marginTop: "3rem",
|
||||
marginBottom: "2rem"
|
||||
}}
|
||||
>
|
||||
<Typography color="info">
|
||||
When running Valetudo in embedded mode, it will advertise its presence on your local network
|
||||
via both Bonjour/mDNS and SSDP/UPnP to enable other software such as the android companion app
|
||||
or the windows explorer to discover it.
|
||||
<br/><br/>
|
||||
Please note that disabling this feature <em>will break</em> the companion app as well as other
|
||||
things that may be able to auto-discover Valetudo instances on your network.
|
||||
</Typography>
|
||||
</InfoBox>
|
||||
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
<Grid container>
|
||||
<Grid item style={{marginLeft: "auto"}}>
|
||||
<LoadingButton
|
||||
loading={configurationUpdating}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
disabled={!configurationModified}
|
||||
onClick={() => {
|
||||
updateConfiguration({
|
||||
enabled: enabled
|
||||
});
|
||||
setConfigurationModified(false);
|
||||
}}
|
||||
>
|
||||
Save configuration
|
||||
</LoadingButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</Grid>
|
||||
</PaperContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkAdvertisementSettings;
|
||||
@ -0,0 +1,152 @@
|
||||
import {
|
||||
Box,
|
||||
Checkbox,
|
||||
Divider,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
Skeleton,
|
||||
TextField,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import {
|
||||
useNetworkAdvertisementConfigurationMutation,
|
||||
useNetworkAdvertisementConfigurationQuery,
|
||||
useNetworkAdvertisementPropertiesQuery
|
||||
} from "../../api";
|
||||
import {LoadingButton} from "@mui/lab";
|
||||
import InfoBox from "../../components/InfoBox";
|
||||
import PaperContainer from "../../components/PaperContainer";
|
||||
import {
|
||||
AutoFixHigh as NetworkAdvertisementIcon
|
||||
} from "@mui/icons-material";
|
||||
import DetailPageHeaderRow from "../../components/DetailPageHeaderRow";
|
||||
|
||||
const NetworkAdvertisementSettings = (): React.ReactElement => {
|
||||
const {
|
||||
data: storedConfiguration,
|
||||
isPending: configurationPending,
|
||||
isError: configurationError,
|
||||
} = useNetworkAdvertisementConfigurationQuery();
|
||||
|
||||
const {
|
||||
data: properties,
|
||||
isPending: propertiesPending,
|
||||
isError: propertiesLoadError
|
||||
} = useNetworkAdvertisementPropertiesQuery();
|
||||
|
||||
const {
|
||||
mutate: updateConfiguration,
|
||||
isPending: configurationUpdating
|
||||
} = useNetworkAdvertisementConfigurationMutation();
|
||||
|
||||
const [enabled, setEnabled] = React.useState(false);
|
||||
|
||||
const [configurationModified, setConfigurationModified] = React.useState<boolean>(false);
|
||||
|
||||
|
||||
React.useEffect(() => {
|
||||
if (storedConfiguration) {
|
||||
setEnabled(storedConfiguration.enabled);
|
||||
}
|
||||
}, [storedConfiguration]);
|
||||
|
||||
if (configurationPending || propertiesPending) {
|
||||
return (
|
||||
<Skeleton height={"8rem"}/>
|
||||
);
|
||||
}
|
||||
|
||||
if (configurationError || propertiesLoadError || !storedConfiguration) {
|
||||
return <Typography color="error">Error loading Network Advertisement configuration</Typography>;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={enabled}
|
||||
onChange={e => {
|
||||
setEnabled(e.target.checked);
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label="Network Advertisement enabled"
|
||||
sx={{mb: 1, marginTop: "1rem", userSelect: "none"}}
|
||||
/>
|
||||
<Grid container spacing={1} sx={{mb: 1, mt: "1rem"}} direction="row">
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width: "100%"}}
|
||||
label="Zeroconf Hostname"
|
||||
value={properties?.zeroconfHostname ?? ""}
|
||||
variant="standard"
|
||||
disabled={true}
|
||||
InputProps={{
|
||||
readOnly: true,
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
|
||||
<InfoBox
|
||||
boxShadow={5}
|
||||
style={{
|
||||
marginTop: "3rem",
|
||||
marginBottom: "2rem"
|
||||
}}
|
||||
>
|
||||
<Typography color="info">
|
||||
When running Valetudo in embedded mode, it will advertise its presence on your local network
|
||||
via both Bonjour/mDNS and SSDP/UPnP to enable other software such as the android companion app
|
||||
or the windows explorer to discover it.
|
||||
<br/><br/>
|
||||
Please note that disabling this feature <em>will break</em> the companion app as well as other
|
||||
things that may be able to auto-discover Valetudo instances on your network.
|
||||
</Typography>
|
||||
</InfoBox>
|
||||
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
<Grid container>
|
||||
<Grid item style={{marginLeft: "auto"}}>
|
||||
<LoadingButton
|
||||
loading={configurationUpdating}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
disabled={!configurationModified}
|
||||
onClick={() => {
|
||||
updateConfiguration({
|
||||
enabled: enabled
|
||||
});
|
||||
setConfigurationModified(false);
|
||||
}}
|
||||
>
|
||||
Save configuration
|
||||
</LoadingButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const NetworkAdvertisementSettingsPage = (): React.ReactElement => {
|
||||
return (
|
||||
<PaperContainer>
|
||||
<Grid container direction="row">
|
||||
<Box style={{width: "100%"}}>
|
||||
<DetailPageHeaderRow
|
||||
title="Network Advertisement"
|
||||
icon={<NetworkAdvertisementIcon/>}
|
||||
/>
|
||||
|
||||
<NetworkAdvertisementSettings/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</PaperContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default NetworkAdvertisementSettingsPage;
|
||||
@ -1,5 +1,6 @@
|
||||
import {
|
||||
Box, Button,
|
||||
Box,
|
||||
Button,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
@ -12,6 +13,7 @@ import {
|
||||
Input,
|
||||
InputAdornment,
|
||||
InputLabel,
|
||||
Skeleton,
|
||||
TextField,
|
||||
Typography,
|
||||
useTheme
|
||||
@ -23,7 +25,6 @@ import {
|
||||
useWifiStatusQuery,
|
||||
WifiStatus
|
||||
} from "../../api";
|
||||
import LoadingFade from "../../components/LoadingFade";
|
||||
import {LoadingButton} from "@mui/lab";
|
||||
|
||||
import {
|
||||
@ -44,7 +45,7 @@ import ConfirmationDialog from "../../components/ConfirmationDialog";
|
||||
import InfoBox from "../../components/InfoBox";
|
||||
import DetailPageHeaderRow from "../../components/DetailPageHeaderRow";
|
||||
|
||||
const WifiStatusComponent : React.FunctionComponent<{
|
||||
const WifiStatusComponent: React.FunctionComponent<{
|
||||
status?: WifiStatus,
|
||||
statusLoading: boolean,
|
||||
statusError: boolean
|
||||
@ -57,7 +58,7 @@ const WifiStatusComponent : React.FunctionComponent<{
|
||||
|
||||
if (statusLoading || !status) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
<Skeleton height={"4rem"}/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -65,7 +66,7 @@ const WifiStatusComponent : React.FunctionComponent<{
|
||||
return <Typography color="error">Error loading Wi-Fi status</Typography>;
|
||||
}
|
||||
|
||||
const getIconForState = () : React.ReactElement => {
|
||||
const getIconForState = (): React.ReactElement => {
|
||||
switch (status.state) {
|
||||
case "not_connected":
|
||||
return <WifiStateNotConnectedIcon sx={{fontSize: "4rem"}}/>;
|
||||
@ -88,7 +89,7 @@ const WifiStatusComponent : React.FunctionComponent<{
|
||||
}
|
||||
};
|
||||
|
||||
const getContentForState = () : React.ReactElement | undefined => {
|
||||
const getContentForState = (): React.ReactElement | undefined => {
|
||||
switch (status.state) {
|
||||
case "not_connected":
|
||||
return (
|
||||
@ -110,7 +111,10 @@ const WifiStatusComponent : React.FunctionComponent<{
|
||||
|
||||
<Typography
|
||||
variant="subtitle2"
|
||||
style={{marginTop: "0.5rem", color: theme.palette.grey[theme.palette.mode === "light" ? 400 : 700]}}
|
||||
style={{
|
||||
marginTop: "0.5rem",
|
||||
color: theme.palette.grey[theme.palette.mode === "light" ? 400 : 700]
|
||||
}}
|
||||
>
|
||||
{status.details.signal} dBm
|
||||
</Typography>
|
||||
@ -142,8 +146,8 @@ const WifiStatusComponent : React.FunctionComponent<{
|
||||
|
||||
|
||||
return (
|
||||
<Grid container alignItems="center" direction="column" style={{paddingBottom:"1rem"}}>
|
||||
<Grid item style={{marginTop:"1rem"}}>
|
||||
<Grid container alignItems="center" direction="column" style={{paddingBottom: "1rem"}}>
|
||||
<Grid item style={{marginTop: "1rem"}}>
|
||||
{getIconForState()}
|
||||
</Grid>
|
||||
<Grid
|
||||
@ -165,9 +169,7 @@ const WifiConnectivity = (): React.ReactElement => {
|
||||
const {
|
||||
data: wifiStatus,
|
||||
isPending: wifiStatusPending,
|
||||
isFetching: wifiStatusFetching,
|
||||
isError: wifiStatusLoadError,
|
||||
refetch: refetchWifiStatus,
|
||||
} = useWifiStatusQuery();
|
||||
|
||||
const {
|
||||
@ -194,7 +196,7 @@ const WifiConnectivity = (): React.ReactElement => {
|
||||
|
||||
if (wifiStatusPending || propertiesPending) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
<Skeleton height={"8rem"}/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -203,120 +205,107 @@ const WifiConnectivity = (): React.ReactElement => {
|
||||
}
|
||||
|
||||
return (
|
||||
<PaperContainer>
|
||||
<Grid container direction="row">
|
||||
<Box style={{width: "100%"}}>
|
||||
<DetailPageHeaderRow
|
||||
title="Wi-Fi Connectivity"
|
||||
icon={<WifiIcon/>}
|
||||
onRefreshClick={() => {
|
||||
refetchWifiStatus().catch(() => {
|
||||
/* intentional */
|
||||
});
|
||||
}}
|
||||
isRefreshing={wifiStatusFetching}
|
||||
/>
|
||||
<>
|
||||
<WifiStatusComponent
|
||||
status={wifiStatus}
|
||||
statusLoading={wifiStatusPending}
|
||||
statusError={wifiStatusLoadError}
|
||||
/>
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
|
||||
<WifiStatusComponent
|
||||
status={wifiStatus}
|
||||
statusLoading={wifiStatusPending}
|
||||
statusError={wifiStatusLoadError}
|
||||
/>
|
||||
<Divider sx={{mt: 1}} style={{marginBottom: "1rem"}}/>
|
||||
<Typography variant="h6" style={{marginBottom: "0.5rem"}}>
|
||||
Change Wi-Fi configuration
|
||||
</Typography>
|
||||
|
||||
<Typography variant="h6" style={{marginBottom: "0.5rem"}}>
|
||||
Change Wi-Fi configuration
|
||||
{
|
||||
properties.provisionedReconfigurationSupported &&
|
||||
<Grid container spacing={1} sx={{mb: 1}} direction="row">
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width: "100%"}}
|
||||
label="SSID/Wi-Fi name"
|
||||
value={newSSID}
|
||||
variant="standard"
|
||||
onChange={e => {
|
||||
setNewSSID(e.target.value);
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<FormControl style={{width: "100%"}} variant="standard">
|
||||
<InputLabel htmlFor="standard-adornment-password">PSK/Password</InputLabel>
|
||||
<Input
|
||||
type={showPasswordAsPlain ? "text" : "password"}
|
||||
fullWidth
|
||||
value={newPSK}
|
||||
sx={{mb: 1}}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={() => {
|
||||
setShowPasswordAsPlain(!showPasswordAsPlain);
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
edge="end"
|
||||
>
|
||||
{showPasswordAsPlain ? <VisibilityOffIcon/> : <VisibilityIcon/>}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
onChange={(e) => {
|
||||
setNewPSK(e.target.value);
|
||||
setConfigurationModified(true);
|
||||
}}/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
}
|
||||
|
||||
{
|
||||
!properties.provisionedReconfigurationSupported &&
|
||||
<InfoBox
|
||||
boxShadow={5}
|
||||
style={{
|
||||
marginTop: "2rem",
|
||||
marginBottom: "2rem"
|
||||
}}
|
||||
>
|
||||
<Typography color="info">
|
||||
To connect your robot to a different Wi-Fi network, you need to do a Wi-Fi reset.
|
||||
<br/><br/>
|
||||
Note that the procedure is different depending on your model of robot, so please refer to the
|
||||
relevant documentation to figure out how to do that.
|
||||
After having done that, simply connect to the Wi-Fi AP provided by the robot and then either use
|
||||
the Valetudo Webinterface
|
||||
or the Companion app to enter new Wi-Fi credentials.
|
||||
</Typography>
|
||||
</InfoBox>
|
||||
}
|
||||
|
||||
{
|
||||
properties.provisionedReconfigurationSupported &&
|
||||
<Grid container spacing={1} sx={{mb: 1}} direction="row">
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<TextField
|
||||
style={{width: "100%"}}
|
||||
label="SSID/Wi-Fi name"
|
||||
value={newSSID}
|
||||
variant="standard"
|
||||
onChange={e => {
|
||||
setNewSSID(e.target.value);
|
||||
setConfigurationModified(true);
|
||||
}}
|
||||
/>
|
||||
</Grid>
|
||||
<Grid item xs="auto" style={{flexGrow: 1}}>
|
||||
<FormControl style={{width: "100%"}} variant="standard">
|
||||
<InputLabel htmlFor="standard-adornment-password">PSK/Password</InputLabel>
|
||||
<Input
|
||||
type={showPasswordAsPlain ? "text" : "password"}
|
||||
fullWidth
|
||||
value={newPSK}
|
||||
sx={{mb: 1}}
|
||||
endAdornment={
|
||||
<InputAdornment position="end">
|
||||
<IconButton
|
||||
aria-label="toggle password visibility"
|
||||
onClick={() => {
|
||||
setShowPasswordAsPlain(!showPasswordAsPlain);
|
||||
}}
|
||||
onMouseDown={e => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
edge="end"
|
||||
>
|
||||
{showPasswordAsPlain ? <VisibilityOffIcon /> : <VisibilityIcon />}
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
}
|
||||
onChange={(e) => {
|
||||
setNewPSK(e.target.value);
|
||||
setConfigurationModified(true);
|
||||
}}/>
|
||||
</FormControl>
|
||||
</Grid>
|
||||
</Grid>
|
||||
}
|
||||
<Divider sx={{mt: 1}} style={{marginTop: "1rem", marginBottom: "1rem"}}/>
|
||||
|
||||
{
|
||||
!properties.provisionedReconfigurationSupported &&
|
||||
<InfoBox
|
||||
boxShadow={5}
|
||||
style={{
|
||||
marginTop: "2rem",
|
||||
marginBottom: "2rem"
|
||||
{
|
||||
properties.provisionedReconfigurationSupported &&
|
||||
<Grid container>
|
||||
<Grid item style={{marginLeft: "auto"}}>
|
||||
<LoadingButton
|
||||
loading={configurationUpdating}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
disabled={!(configurationModified && newSSID && newPSK)}
|
||||
onClick={() => {
|
||||
setConfirmationDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Typography color="info">
|
||||
To connect your robot to a different Wi-Fi network, you need to do a Wi-Fi reset.
|
||||
<br/><br/>
|
||||
Note that the procedure is different depending on your model of robot, so please refer to the relevant documentation to figure out how to do that.
|
||||
After having done that, simply connect to the Wi-Fi AP provided by the robot and then either use the Valetudo Webinterface
|
||||
or the Companion app to enter new Wi-Fi credentials.
|
||||
</Typography>
|
||||
</InfoBox>
|
||||
}
|
||||
|
||||
<Divider sx={{mt: 1}} style={{marginTop: "1rem", marginBottom: "1rem"}}/>
|
||||
|
||||
{
|
||||
properties.provisionedReconfigurationSupported &&
|
||||
<Grid container>
|
||||
<Grid item style={{marginLeft: "auto"}}>
|
||||
<LoadingButton
|
||||
loading={configurationUpdating}
|
||||
color="primary"
|
||||
variant="outlined"
|
||||
disabled={!(configurationModified && newSSID && newPSK)}
|
||||
onClick={() => {
|
||||
setConfirmationDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
Save configuration
|
||||
</LoadingButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
}
|
||||
</Box>
|
||||
</Grid>
|
||||
Save configuration
|
||||
</LoadingButton>
|
||||
</Grid>
|
||||
</Grid>
|
||||
}
|
||||
<ConfirmationDialog
|
||||
title="Apply new Wi-Fi configuration?"
|
||||
text=""
|
||||
@ -365,8 +354,36 @@ const WifiConnectivity = (): React.ReactElement => {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const WifiConnectivityPage = (): React.ReactElement => {
|
||||
const {
|
||||
isFetching: wifiStatusFetching,
|
||||
refetch: refetchWifiStatus,
|
||||
} = useWifiStatusQuery();
|
||||
|
||||
return (
|
||||
<PaperContainer>
|
||||
<Grid container direction="row">
|
||||
<Box style={{width: "100%"}}>
|
||||
<DetailPageHeaderRow
|
||||
title="Wi-Fi Connectivity"
|
||||
icon={<WifiIcon/>}
|
||||
onRefreshClick={() => {
|
||||
refetchWifiStatus().catch(() => {
|
||||
/* intentional */
|
||||
});
|
||||
}}
|
||||
isRefreshing={wifiStatusFetching}
|
||||
/>
|
||||
|
||||
<WifiConnectivity/>
|
||||
</Box>
|
||||
</Grid>
|
||||
</PaperContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default WifiConnectivity;
|
||||
export default WifiConnectivityPage;
|
||||
@ -8,7 +8,7 @@ import {
|
||||
useConsumableResetMutation,
|
||||
useConsumableStateQuery
|
||||
} from "../api";
|
||||
import {CircularProgress, LinearProgress} from "@mui/material";
|
||||
import {LinearProgress, Skeleton} from "@mui/material";
|
||||
import {ButtonListMenuItem} from "../components/list_menu/ButtonListMenuItem";
|
||||
import {convertSecondsToHumans, getConsumableName} from "../utils";
|
||||
import {ConsumablesHelp} from "./res/ConsumablesHelp";
|
||||
@ -126,7 +126,7 @@ const Consumables = (): React.ReactElement => {
|
||||
{
|
||||
(consumablePropertiesPending || consumablesDataPending) &&
|
||||
<div style={{display: "flex", justifyContent: "center"}}>
|
||||
<CircularProgress/>
|
||||
<Skeleton height={"24rem"} width={"100%"}/>
|
||||
</div>
|
||||
|
||||
}
|
||||
|
||||
@ -2,14 +2,13 @@ import React from "react";
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Collapse,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
LinearProgress,
|
||||
Stack,
|
||||
Switch,
|
||||
Typography,
|
||||
styled,
|
||||
Skeleton,
|
||||
} from "@mui/material";
|
||||
import {
|
||||
Capability,
|
||||
@ -139,10 +138,11 @@ const ManualControlInternal: React.FunctionComponent = (): React.ReactElement =>
|
||||
<FullHeightGrid container direction="column">
|
||||
<Grid item flexGrow={1}>
|
||||
<Box>
|
||||
<Collapse in={loading}>
|
||||
<LinearProgress/>
|
||||
</Collapse>
|
||||
{controls}
|
||||
{
|
||||
loading &&
|
||||
<Skeleton height={"12rem"}/>
|
||||
}
|
||||
{!loading && controls}
|
||||
</Box>
|
||||
</Grid>
|
||||
</FullHeightGrid>
|
||||
|
||||
@ -8,13 +8,13 @@ import {
|
||||
DialogTitle,
|
||||
Grid,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Typography,
|
||||
useTheme,
|
||||
} from "@mui/material";
|
||||
import {Capability, useTotalStatisticsQuery, ValetudoDataPoint} from "../api";
|
||||
import {useCapabilitiesSupported} from "../CapabilitiesProvider";
|
||||
import PaperContainer from "../components/PaperContainer";
|
||||
import LoadingFade from "../components/LoadingFade";
|
||||
import {adjustColorBrightness, getFriendlyStatName, getHumanReadableStatValue} from "../utils";
|
||||
import {History as HistoryIcon} from "@mui/icons-material";
|
||||
import {StatisticsAchievement, statisticsAchievements} from "./res/StatisticsAchievements";
|
||||
@ -268,7 +268,7 @@ const TotalStatisticsInternal: React.FunctionComponent = (): React.ReactElement
|
||||
return React.useMemo(() => {
|
||||
if (totalStatisticsPending) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
<Skeleton height={"24rem"}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -35,7 +35,6 @@ import RatioBar from "../components/RatioBar";
|
||||
import {convertSecondsToHumans} from "../utils";
|
||||
import {useIsMobileView} from "../hooks";
|
||||
import ReloadableCard from "../components/ReloadableCard";
|
||||
import LoadingFade from "../components/LoadingFade";
|
||||
import PaperContainer from "../components/PaperContainer";
|
||||
import TextInformationGrid from "../components/TextInformationGrid";
|
||||
|
||||
@ -61,7 +60,7 @@ const SystemRuntimeInfo = (): React.ReactElement => {
|
||||
|
||||
const systemRuntimeInformation = React.useMemo(() => {
|
||||
if (systemRuntimeInfoPending) {
|
||||
return <Skeleton/>;
|
||||
return <Skeleton height={"6rem"}/>;
|
||||
}
|
||||
|
||||
if (!systemRuntimeInfo) {
|
||||
@ -252,7 +251,7 @@ const SystemInformation = (): React.ReactElement => {
|
||||
const valetudoInformationView = React.useMemo(() => {
|
||||
if (valetudoInformationViewLoading) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
<Skeleton height={"4rem"}/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -292,7 +291,7 @@ const SystemInformation = (): React.ReactElement => {
|
||||
const robotInformationView = React.useMemo(() => {
|
||||
if (robotInformationViewLoading) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
<Skeleton height={"4rem"}/>
|
||||
);
|
||||
}
|
||||
|
||||
@ -330,7 +329,7 @@ const SystemInformation = (): React.ReactElement => {
|
||||
const systemHostInformation = React.useMemo(() => {
|
||||
if (systemHostInfoPending) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
<Skeleton height={"12rem"}/>
|
||||
);
|
||||
}
|
||||
if (!systemHostInfo) {
|
||||
|
||||
@ -23,10 +23,10 @@ import {
|
||||
Divider,
|
||||
Grid,
|
||||
LinearProgress,
|
||||
Skeleton,
|
||||
Typography
|
||||
} from "@mui/material";
|
||||
import React from "react";
|
||||
import LoadingFade from "../components/LoadingFade";
|
||||
import {LoadingButton} from "@mui/lab";
|
||||
import ConfirmationDialog from "../components/ConfirmationDialog";
|
||||
|
||||
@ -81,7 +81,7 @@ const UpdaterStateComponent : React.FunctionComponent<{ state: UpdaterState | un
|
||||
}) => {
|
||||
if (stateLoading || !state) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
<Skeleton height={"12rem"}/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -2,6 +2,7 @@ import {
|
||||
Fab,
|
||||
Grid,
|
||||
IconButton,
|
||||
Skeleton,
|
||||
Typography,
|
||||
} from "@mui/material";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
@ -21,7 +22,6 @@ import {
|
||||
import TimerCard from "./TimerCard";
|
||||
import TimerEditDialog from "./TimerEditDialog";
|
||||
import { deepCopy } from "../../utils";
|
||||
import LoadingFade from "../../components/LoadingFade";
|
||||
import {Help as HelpIcon} from "@mui/icons-material";
|
||||
import HelpDialog from "../../components/HelpDialog";
|
||||
import {TimersHelp} from "./res/TimersHelp";
|
||||
@ -103,7 +103,9 @@ const Timers = (): React.ReactElement => {
|
||||
|
||||
if (timerDataPending || timerPropertiesPending) {
|
||||
return (
|
||||
<LoadingFade/>
|
||||
<PaperContainer>
|
||||
<Skeleton height={"16rem"}/>
|
||||
</PaperContainer>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user