feat(ui): Loading animation cleanup

This commit is contained in:
Sören Beye 2024-05-28 18:49:01 +02:00
parent 31519e164c
commit d8714ddeb9
21 changed files with 1748 additions and 1656 deletions

View File

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

View File

@ -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"}/>
);
}

View File

@ -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 {

View File

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

View File

@ -22,7 +22,7 @@ export const useCommittingSlider = (initialValue: number, onChange: (value: numb
setResetTimeout(setTimeout(() => {
setSliderValue(initialValue);
}, 1000));
}, 2000));
}
}, [sliderValue, initialValue, adoptedValue, getResetTimeout]);

View File

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

View File

@ -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&apos;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&apos;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;

View 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&apos;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&apos;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;

View File

@ -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&apos;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&apos;re using Mosquitto but still experience issues, make sure that your ACLs (if any) are correct and
you&apos;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&apos;t part of the MQTT v3.1.1 spec. MQTT v5 fixes this issue but isn&apos;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;

View 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&apos;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&apos;re using Mosquitto but still experience issues, make sure that your ACLs (if any) are
correct and
you&apos;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&apos;t part of the MQTT v3.1.1 spec. MQTT v5 fixes this issue but isn&apos;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;

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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"}/>
);
}

View File

@ -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) {

View File

@ -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"}/>
);
}

View File

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