Merge remote-tracking branch 'origin/v3.4' into dev

This commit is contained in:
proddy
2022-01-23 17:56:52 +01:00
parent 02e2b51814
commit 77e1898512
538 changed files with 32282 additions and 38655 deletions

View File

@@ -1,57 +1,45 @@
import React, { Component, RefObject } from 'react';
import { Redirect, Route, Switch } from 'react-router';
import { FC, createRef, createContext, useContext, RefObject } from 'react';
import { SnackbarProvider } from 'notistack';
import { IconButton } from '@material-ui/core';
import CloseIcon from '@material-ui/icons/Close';
import { IconButton } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { FeaturesLoader } from './contexts/features';
import CustomTheme from './CustomTheme';
import AppRouting from './AppRouting';
import CustomMuiTheme from './CustomMuiTheme';
import { PROJECT_NAME } from './api';
import FeaturesWrapper from './features/FeaturesWrapper';
// this redirect forces a call to authenticationContext.refresh() which invalidates the JWT if it is invalid.
const unauthorizedRedirect = () => <Redirect to="/" />;
const App: FC = () => {
const notistackRef: RefObject<any> = createRef();
class App extends Component {
notistackRef: RefObject<any> = React.createRef();
componentDidMount() {
document.title = PROJECT_NAME;
}
onClickDismiss = (key: string | number | undefined) => () => {
this.notistackRef.current.closeSnackbar(key);
const onClickDismiss = (key: string | number | undefined) => () => {
notistackRef.current.closeSnackbar(key);
};
render() {
return (
<CustomMuiTheme>
const ColorModeContext = createContext({ toggleColorMode: () => {} });
const colorMode = useContext(ColorModeContext);
return (
<ColorModeContext.Provider value={colorMode}>
<CustomTheme>
<SnackbarProvider
autoHideDuration={3000}
maxSnack={3}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
ref={this.notistackRef}
ref={notistackRef}
action={(key) => (
<IconButton onClick={this.onClickDismiss(key)} size="small">
<IconButton onClick={onClickDismiss(key)} size="small">
<CloseIcon />
</IconButton>
)}
>
<FeaturesWrapper>
<Switch>
<Route
exact
path="/unauthorized"
component={unauthorizedRedirect}
/>
<Route component={AppRouting} />
</Switch>
</FeaturesWrapper>
<FeaturesLoader>
<AppRouting />
</FeaturesLoader>
</SnackbarProvider>
</CustomMuiTheme>
);
}
}
</CustomTheme>
</ColorModeContext.Provider>
);
};
export default App;

View File

@@ -1,67 +1,77 @@
import React, { Component } from 'react';
import { Switch, Redirect } from 'react-router';
import { FC, useContext, useEffect } from 'react';
import { Navigate, Routes, Route, useLocation } from 'react-router-dom';
import { useSnackbar, VariantType } from 'notistack';
import * as Authentication from './authentication/Authentication';
import AuthenticationWrapper from './authentication/AuthenticationWrapper';
import UnauthenticatedRoute from './authentication/UnauthenticatedRoute';
import AuthenticatedRoute from './authentication/AuthenticatedRoute';
import { Authentication, AuthenticationContext } from './contexts/authentication';
import { FeaturesContext } from './contexts/features';
import { RequireAuthenticated, RequireUnauthenticated } from './components';
import SignIn from './SignIn';
import ProjectRouting from './project/ProjectRouting';
import NetworkConnection from './network/NetworkConnection';
import AccessPoint from './ap/AccessPoint';
import NetworkTime from './ntp/NetworkTime';
import Security from './security/Security';
import System from './system/System';
import AuthenticatedRouting from './AuthenticatedRouting';
import { PROJECT_PATH } from './api';
import Mqtt from './mqtt/Mqtt';
import { withFeatures, WithFeaturesProps } from './features/FeaturesContext';
import { Features } from './features/types';
export const getDefaultRoute = (features: Features) =>
features.project ? `/${PROJECT_PATH}/` : '/network/';
class AppRouting extends Component<WithFeaturesProps> {
componentDidMount() {
Authentication.clearLoginRedirect();
}
render() {
const { features } = this.props;
return (
<AuthenticationWrapper>
<Switch>
{features.security && (
<UnauthenticatedRoute exact path="/" component={SignIn} />
)}
{features.project && (
<AuthenticatedRoute
exact
path={`/${PROJECT_PATH}/*`}
component={ProjectRouting}
/>
)}
<AuthenticatedRoute
exact
path="/network/*"
component={NetworkConnection}
/>
<AuthenticatedRoute exact path="/ap/*" component={AccessPoint} />
{features.ntp && (
<AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} />
)}
{features.mqtt && (
<AuthenticatedRoute exact path="/mqtt/*" component={Mqtt} />
)}
{features.security && (
<AuthenticatedRoute exact path="/security/*" component={Security} />
)}
<AuthenticatedRoute exact path="/system/*" component={System} />
<Redirect to={getDefaultRoute(features)} />
</Switch>
</AuthenticationWrapper>
);
}
interface SecurityRedirectProps {
message: string;
variant?: VariantType;
signOut?: boolean;
}
export default withFeatures(AppRouting);
const RootRedirect: FC<SecurityRedirectProps> = ({ message, variant, signOut }) => {
const authenticationContext = useContext(AuthenticationContext);
const { enqueueSnackbar } = useSnackbar();
useEffect(() => {
signOut && authenticationContext.signOut(false);
enqueueSnackbar(message, { variant });
}, [message, variant, signOut, authenticationContext, enqueueSnackbar]);
return <Navigate to="/" />;
};
export const RemoveTrailingSlashes = () => {
const location = useLocation();
return (
location.pathname.match('/.*/$') && (
<Navigate
to={{
pathname: location.pathname.replace(/\/+$/, ''),
search: location.search
}}
/>
)
);
};
const AppRouting: FC = () => {
const { features } = useContext(FeaturesContext);
return (
<Authentication>
<RemoveTrailingSlashes />
<Routes>
<Route path="/unauthorized" element={<RootRedirect message="Please sign in to continue" signOut />} />
<Route
path="/firmwareUpdated"
element={<RootRedirect message="Firmware update successful" variant="success" />}
/>
{features.security && (
<Route
path="/"
element={
<RequireUnauthenticated>
<SignIn />
</RequireUnauthenticated>
}
/>
)}
<Route
path="/*"
element={
<RequireAuthenticated>
<AuthenticatedRouting />
</RequireAuthenticated>
}
/>
</Routes>
</Authentication>
);
};
export default AppRouting;

View File

@@ -0,0 +1,66 @@
import { FC, useCallback, useContext, useEffect } from 'react';
import { Navigate, Routes, Route, useNavigate, useLocation } from 'react-router-dom';
import { AxiosError } from 'axios';
import { FeaturesContext } from './contexts/features';
import * as AuthenticationApi from './api/authentication';
import { PROJECT_PATH } from './api/env';
import { AXIOS } from './api/endpoints';
import { Layout, RequireAdmin } from './components';
import ProjectRouting from './project/ProjectRouting';
import NetworkConnection from './framework/network/NetworkConnection';
import AccessPoint from './framework/ap/AccessPoint';
import NetworkTime from './framework/ntp/NetworkTime';
import Mqtt from './framework/mqtt/Mqtt';
import System from './framework/system/System';
import Security from './framework/security/Security';
const AuthenticatedRouting: FC = () => {
const { features } = useContext(FeaturesContext);
const location = useLocation();
const navigate = useNavigate();
const handleApiResponseError = useCallback(
(error: AxiosError) => {
if (error.response && error.response.status === 401) {
AuthenticationApi.storeLoginRedirect(location);
navigate('/unauthorized');
}
return Promise.reject(error);
},
[location, navigate]
);
useEffect(() => {
const axiosHandlerId = AXIOS.interceptors.response.use((response) => response, handleApiResponseError);
return () => AXIOS.interceptors.response.eject(axiosHandlerId);
}, [handleApiResponseError]);
return (
<Layout>
<Routes>
{features.project && <Route path={`/${PROJECT_PATH}/*`} element={<ProjectRouting />} />}
<Route path="/network/*" element={<NetworkConnection />} />
<Route path="/ap/*" element={<AccessPoint />} />
{features.ntp && <Route path="/ntp/*" element={<NetworkTime />} />}
{features.mqtt && <Route path="/mqtt/*" element={<Mqtt />} />}
{features.security && (
<Route
path="/security/*"
element={
<RequireAdmin>
<Security />
</RequireAdmin>
}
/>
)}
<Route path="/system/*" element={<System />} />
<Route path="/*" element={<Navigate to={AuthenticationApi.getDefaultRoute(features)} />} />
</Routes>
</Layout>
);
};
export default AuthenticatedRouting;

View File

@@ -1,46 +0,0 @@
import { Component } from 'react';
import { CssBaseline } from '@material-ui/core';
import {
MuiThemeProvider,
createTheme,
StylesProvider
} from '@material-ui/core/styles';
import { blueGrey, orange, red, green } from '@material-ui/core/colors';
const theme = createTheme({
palette: {
type: 'dark',
primary: {
main: '#33bfff'
},
secondary: {
main: '#3d5afe'
},
info: {
main: blueGrey[500]
},
warning: {
main: orange[500]
},
error: {
main: red[500]
},
success: {
main: green[500]
}
}
});
export default class CustomMuiTheme extends Component {
render() {
return (
<StylesProvider>
<MuiThemeProvider theme={theme}>
<CssBaseline />
{this.props.children}
</MuiThemeProvider>
</StylesProvider>
);
}
}

View File

@@ -0,0 +1,47 @@
import { FC } from 'react';
import { CssBaseline } from '@mui/material';
import { createTheme, responsiveFontSizes, ThemeProvider } from '@mui/material/styles';
import { blueGrey, blue } from '@mui/material/colors';
const theme = responsiveFontSizes(
createTheme({
typography: {
fontSize: 13
},
palette: {
mode: 'dark',
// background: {
// default: grey[900], // #212121
// // paper: grey[800]
// },
// primary: {
// main: '#33bfff'
// },
secondary: {
main: blue[500] // in buttons
},
info: {
main: blueGrey[500] // used in icons
}
// warning: {
// main: orange[500]
// },
// error: {
// main: red[200]
// },
// success: {
// main: green[700]
// }
}
})
);
const CustomTheme: FC = ({ children }) => (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
);
export default CustomTheme;

View File

@@ -1,165 +1,113 @@
import React, { Component } from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack';
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import { FC, useContext, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { useSnackbar } from 'notistack';
import {
withStyles,
createStyles,
Theme,
WithStyles
} from '@material-ui/core/styles';
import { Paper, Typography, Fab } from '@material-ui/core';
import ForwardIcon from '@material-ui/icons/Forward';
import { Box, Fab, Paper, Typography } from '@mui/material';
import ForwardIcon from '@mui/icons-material/Forward';
import {
withAuthenticationContext,
AuthenticationContextProps
} from './authentication/AuthenticationContext';
import { PasswordValidator } from './components';
import { PROJECT_NAME, SIGN_IN_ENDPOINT } from './api';
import * as AuthenticationApi from './api/authentication';
import { PROJECT_NAME } from './api/env';
import { AuthenticationContext } from './contexts/authentication';
const styles = (theme: Theme) =>
createStyles({
signInPage: {
display: 'flex',
height: '100vh',
margin: 'auto',
padding: theme.spacing(2),
justifyContent: 'center',
flexDirection: 'column',
maxWidth: theme.breakpoints.values.sm
},
signInPanel: {
textAlign: 'center',
padding: theme.spacing(2),
paddingTop: '200px',
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: 'no-repeat',
backgroundPosition: '50% ' + theme.spacing(2) + 'px',
backgroundSize: 'auto 150px',
width: '100%'
},
extendedIcon: {
marginRight: theme.spacing(0.5)
},
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2)
}
import { extractErrorMessage, onEnterCallback, updateValue } from './utils';
import { SignInRequest } from './types';
import { ValidatedTextField } from './components';
import { SIGN_IN_REQUEST_VALIDATOR, validate } from './validators';
const SignIn: FC = () => {
const authenticationContext = useContext(AuthenticationContext);
const { enqueueSnackbar } = useSnackbar();
const [signInRequest, setSignInRequest] = useState<SignInRequest>({
username: '',
password: ''
});
const [processing, setProcessing] = useState<boolean>(false);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
type SignInProps = WithSnackbarProps &
WithStyles<typeof styles> &
AuthenticationContextProps;
const updateLoginRequestValue = updateValue(setSignInRequest);
interface SignInState {
username: string;
password: string;
processing: boolean;
}
class SignIn extends Component<SignInProps, SignInState> {
constructor(props: SignInProps) {
super(props);
this.state = {
username: '',
password: '',
processing: false
};
}
updateInputElement = (event: React.ChangeEvent<HTMLInputElement>): void => {
const { name, value } = event.currentTarget;
this.setState((prevState) => ({
...prevState,
[name]: value
}));
const validateAndSignIn = async () => {
setProcessing(true);
try {
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
signIn();
} catch (errors: any) {
setFieldErrors(errors);
setProcessing(false);
}
};
onSubmit = () => {
const { username, password } = this.state;
const { authenticationContext } = this.props;
this.setState({ processing: true });
fetch(SIGN_IN_ENDPOINT, {
method: 'POST',
body: JSON.stringify({ username, password }),
headers: new Headers({
'Content-Type': 'application/json'
})
})
.then((response) => {
if (response.status === 200) {
return response.json();
} else if (response.status === 401) {
throw Error('Invalid credentials.');
} else {
throw Error('Invalid status code: ' + response.status);
}
})
.then((json) => {
authenticationContext.signIn(json.access_token);
})
.catch((error) => {
this.props.enqueueSnackbar(error.message, {
variant: 'warning'
});
this.setState({ processing: false });
});
const signIn = async () => {
try {
const { data: loginResponse } = await AuthenticationApi.signIn(signInRequest);
authenticationContext.signIn(loginResponse.access_token);
} catch (error: any) {
if (error.response?.status === 401) {
enqueueSnackbar('Invalid login details', { variant: 'warning' });
} else {
enqueueSnackbar(extractErrorMessage(error, 'Unexpected error, please try again'), { variant: 'error' });
}
setProcessing(false);
}
};
render() {
const { username, password, processing } = this.state;
const { classes } = this.props;
return (
<div className={classes.signInPage}>
<Paper className={classes.signInPanel}>
<Typography variant="h4">{PROJECT_NAME}</Typography>
<ValidatorForm onSubmit={this.onSubmit}>
<TextValidator
disabled={processing}
validators={['required']}
errorMessages={['Username is required']}
name="username"
label="Username"
fullWidth
variant="outlined"
value={username}
onChange={this.updateInputElement}
margin="normal"
inputProps={{
autoCapitalize: 'none',
autoCorrect: 'off'
}}
/>
<PasswordValidator
disabled={processing}
validators={['required']}
errorMessages={['Password is required']}
name="password"
label="Password"
fullWidth
variant="outlined"
value={password}
onChange={this.updateInputElement}
margin="normal"
/>
<Fab
variant="extended"
color="primary"
className={classes.button}
type="submit"
disabled={processing}
>
<ForwardIcon className={classes.extendedIcon} />
Sign In
</Fab>
</ValidatorForm>
</Paper>
</div>
);
}
}
const submitOnEnter = onEnterCallback(signIn);
export default withAuthenticationContext(
withSnackbar(withStyles(styles)(SignIn))
);
return (
<Box
display="flex"
height="100vh"
margin="auto"
padding={2}
justifyContent="center"
flexDirection="column"
maxWidth={(theme) => theme.breakpoints.values.sm}
>
<Paper
sx={(theme) => ({
textAlign: 'center',
padding: theme.spacing(2),
paddingTop: '200px',
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: 'no-repeat',
backgroundPosition: '50% ' + theme.spacing(2),
backgroundSize: 'auto 150px',
width: '100%'
})}
>
<Typography variant="h4">{PROJECT_NAME}</Typography>
<ValidatedTextField
fieldErrors={fieldErrors}
disabled={processing}
name="username"
label="Username"
value={signInRequest.username}
onChange={updateLoginRequestValue}
margin="normal"
variant="outlined"
fullWidth
/>
<ValidatedTextField
fieldErrors={fieldErrors}
disabled={processing}
type="password"
name="password"
label="Password"
value={signInRequest.password}
onChange={updateLoginRequestValue}
onKeyDown={submitOnEnter}
margin="normal"
variant="outlined"
fullWidth
/>
<Fab variant="extended" color="primary" sx={{ mt: 2 }} onClick={validateAndSignIn} disabled={processing}>
<ForwardIcon sx={{ mr: 1 }} />
Sign In
</Fab>
</Paper>
</Box>
);
};
export default SignIn;

View File

@@ -1,8 +0,0 @@
import { APSettings, APProvisionMode } from './types';
export const isAPEnabled = ({ provision_mode }: APSettings) => {
return (
provision_mode === APProvisionMode.AP_MODE_ALWAYS ||
provision_mode === APProvisionMode.AP_MODE_DISCONNECTED
);
};

View File

@@ -1,33 +0,0 @@
import { Component } from 'react';
import { AP_SETTINGS_ENDPOINT } from '../api';
import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import APSettingsForm from './APSettingsForm';
import { APSettings } from './types';
type APSettingsControllerProps = RestControllerProps<APSettings>;
class APSettingsController extends Component<APSettingsControllerProps> {
componentDidMount() {
this.props.loadData();
}
render() {
return (
<SectionContent title="Access Point Settings" titleGutter>
<RestFormLoader
{...this.props}
render={(formProps) => <APSettingsForm {...formProps} />}
/>
</SectionContent>
);
}
}
export default restController(AP_SETTINGS_ENDPOINT, APSettingsController);

View File

@@ -1,134 +0,0 @@
import React, { Fragment } from 'react';
import {
TextValidator,
ValidatorForm,
SelectValidator
} from 'react-material-ui-form-validator';
import MenuItem from '@material-ui/core/MenuItem';
import SaveIcon from '@material-ui/icons/Save';
import {
PasswordValidator,
RestFormProps,
FormActions,
FormButton
} from '../components';
import { isAPEnabled } from './APModes';
import { APSettings, APProvisionMode } from './types';
import { isIP } from '../validators';
type APSettingsFormProps = RestFormProps<APSettings>;
class APSettingsForm extends React.Component<APSettingsFormProps> {
componentDidMount() {
ValidatorForm.addValidationRule('isIP', isIP);
}
render() {
const { data, handleValueChange, saveData } = this.props;
return (
<ValidatorForm onSubmit={saveData} ref="APSettingsForm">
<SelectValidator
name="provision_mode"
label="Provide Access Point&hellip;"
value={data.provision_mode}
fullWidth
variant="outlined"
onChange={handleValueChange('provision_mode')}
margin="normal"
>
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>Always</MenuItem>
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>
When Network Disconnected
</MenuItem>
<MenuItem value={APProvisionMode.AP_NEVER}>Never</MenuItem>
</SelectValidator>
{isAPEnabled(data) && (
<Fragment>
<TextValidator
validators={['required', 'matchRegexp:^.{1,32}$']}
errorMessages={[
'Access Point SSID is required',
'Access Point SSID must be 32 characters or less'
]}
name="ssid"
label="Access Point SSID"
fullWidth
variant="outlined"
value={data.ssid}
onChange={handleValueChange('ssid')}
margin="normal"
/>
<PasswordValidator
validators={['required', 'matchRegexp:^.{8,64}$']}
errorMessages={[
'Access Point Password is required',
'Access Point Password must be 8-64 characters'
]}
name="password"
label="Access Point Password"
fullWidth
variant="outlined"
value={data.password}
onChange={handleValueChange('password')}
margin="normal"
/>
<TextValidator
validators={['required', 'isIP']}
errorMessages={['Local IP is required', 'Must be an IP address']}
name="local_ip"
label="Local IP"
fullWidth
variant="outlined"
value={data.local_ip}
onChange={handleValueChange('local_ip')}
margin="normal"
/>
<TextValidator
validators={['required', 'isIP']}
errorMessages={[
'Gateway IP is required',
'Must be an IP address'
]}
name="gateway_ip"
label="Gateway"
fullWidth
variant="outlined"
value={data.gateway_ip}
onChange={handleValueChange('gateway_ip')}
margin="normal"
/>
<TextValidator
validators={['required', 'isIP']}
errorMessages={[
'Subnet mask is required',
'Must be an IP address'
]}
name="subnet_mask"
label="Subnet"
fullWidth
variant="outlined"
value={data.subnet_mask}
onChange={handleValueChange('subnet_mask')}
margin="normal"
/>
</Fragment>
)}
<FormActions>
<FormButton
startIcon={<SaveIcon />}
variant="contained"
color="primary"
type="submit"
>
Save
</FormButton>
</FormActions>
</ValidatorForm>
);
}
}
export default APSettingsForm;

View File

@@ -1,28 +0,0 @@
import { Theme } from '@material-ui/core';
import { APStatus, APNetworkStatus } from './types';
export const apStatusHighlight = ({ status }: APStatus, theme: Theme) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return theme.palette.success.main;
case APNetworkStatus.INACTIVE:
return theme.palette.info.main;
case APNetworkStatus.LINGERING:
return theme.palette.warning.main;
default:
return theme.palette.warning.main;
}
};
export const apStatus = ({ status }: APStatus) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return 'Active';
case APNetworkStatus.INACTIVE:
return 'Inactive';
case APNetworkStatus.LINGERING:
return 'Lingering until idle';
default:
return 'Unknown';
}
};

View File

@@ -1,33 +0,0 @@
import { Component } from 'react';
import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { AP_STATUS_ENDPOINT } from '../api';
import APStatusForm from './APStatusForm';
import { APStatus } from './types';
type APStatusControllerProps = RestControllerProps<APStatus>;
class APStatusController extends Component<APStatusControllerProps> {
componentDidMount() {
this.props.loadData();
}
render() {
return (
<SectionContent title="Access Point Status">
<RestFormLoader
{...this.props}
render={(formProps) => <APStatusForm {...formProps} />}
/>
</SectionContent>
);
}
}
export default restController(AP_STATUS_ENDPOINT, APStatusController);

View File

@@ -1,91 +0,0 @@
import React, { Component, Fragment } from 'react';
import { WithTheme, withTheme } from '@material-ui/core/styles';
import {
Avatar,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText
} from '@material-ui/core';
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import ComputerIcon from '@material-ui/icons/Computer';
import RefreshIcon from '@material-ui/icons/Refresh';
import {
RestFormProps,
FormActions,
FormButton,
HighlightAvatar
} from '../components';
import { apStatusHighlight, apStatus } from './APStatus';
import { APStatus } from './types';
type APStatusFormProps = RestFormProps<APStatus> & WithTheme;
class APStatusForm extends Component<APStatusFormProps> {
createListItems() {
const { data, theme } = this.props;
return (
<Fragment>
<ListItem>
<ListItemAvatar>
<HighlightAvatar color={apStatusHighlight(data, theme)}>
<SettingsInputAntennaIcon />
</HighlightAvatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={apStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>IP</Avatar>
</ListItemAvatar>
<ListItemText primary="IP Address" secondary={data.ip_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="MAC Address" secondary={data.mac_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<ComputerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="AP Clients" secondary={data.station_num} />
</ListItem>
<Divider variant="inset" component="li" />
</Fragment>
);
}
render() {
return (
<Fragment>
<List>{this.createListItems()}</List>
<FormActions>
<FormButton
startIcon={<RefreshIcon />}
variant="contained"
color="secondary"
onClick={this.props.loadData}
>
Refresh
</FormButton>
</FormActions>
</Fragment>
);
}
}
export default withTheme(APStatusForm);

View File

@@ -1,57 +0,0 @@
import { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
import { Tabs, Tab } from '@material-ui/core';
import {
AuthenticatedContextProps,
withAuthenticatedContext,
AuthenticatedRoute
} from '../authentication';
import { MenuAppBar } from '../components';
import APSettingsController from './APSettingsController';
import APStatusController from './APStatusController';
type AccessPointProps = AuthenticatedContextProps & RouteComponentProps;
class AccessPoint extends Component<AccessPointProps> {
handleTabChange = (path: string) => {
this.props.history.push(path);
};
render() {
const { authenticatedContext } = this.props;
return (
<MenuAppBar sectionTitle="Access Point">
<Tabs
value={this.props.match.url}
onChange={(e, path) => this.handleTabChange(path)}
variant="fullWidth"
>
<Tab value="/ap/status" label="Access Point Status" />
<Tab
value="/ap/settings"
label="Access Point Settings"
disabled={!authenticatedContext.me.admin}
/>
</Tabs>
<Switch>
<AuthenticatedRoute
exact
path="/ap/status"
component={APStatusController}
/>
<AuthenticatedRoute
exact
path="/ap/settings"
component={APSettingsController}
/>
<Redirect to="/ap/status" />
</Switch>
</MenuAppBar>
);
}
}
export default withAuthenticatedContext(AccessPoint);

View File

@@ -1,24 +0,0 @@
import { ENDPOINT_ROOT } from './Env';
export const FEATURES_ENDPOINT = ENDPOINT_ROOT + 'features';
export const NTP_STATUS_ENDPOINT = ENDPOINT_ROOT + 'ntpStatus';
export const NTP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'ntpSettings';
export const TIME_ENDPOINT = ENDPOINT_ROOT + 'time';
export const AP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'apSettings';
export const AP_STATUS_ENDPOINT = ENDPOINT_ROOT + 'apStatus';
export const SCAN_NETWORKS_ENDPOINT = ENDPOINT_ROOT + 'scanNetworks';
export const LIST_NETWORKS_ENDPOINT = ENDPOINT_ROOT + 'listNetworks';
export const NETWORK_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'networkSettings';
export const NETWORK_STATUS_ENDPOINT = ENDPOINT_ROOT + 'networkStatus';
export const OTA_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'otaSettings';
export const UPLOAD_FIRMWARE_ENDPOINT = ENDPOINT_ROOT + 'uploadFirmware';
export const MQTT_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'mqttSettings';
export const MQTT_STATUS_ENDPOINT = ENDPOINT_ROOT + 'mqttStatus';
export const SYSTEM_STATUS_ENDPOINT = ENDPOINT_ROOT + 'systemStatus';
export const SIGN_IN_ENDPOINT = ENDPOINT_ROOT + 'signIn';
export const VERIFY_AUTHORIZATION_ENDPOINT =
ENDPOINT_ROOT + 'verifyAuthorization';
export const SECURITY_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'securitySettings';
export const GENERATE_TOKEN_ENDPOINT = ENDPOINT_ROOT + 'generateToken';
export const RESTART_ENDPOINT = ENDPOINT_ROOT + 'restart';
export const FACTORY_RESET_ENDPOINT = ENDPOINT_ROOT + 'factoryReset';

View File

@@ -1,26 +0,0 @@
export const PROJECT_NAME = process.env.REACT_APP_PROJECT_NAME!;
export const PROJECT_PATH = process.env.REACT_APP_PROJECT_PATH!;
export const ENDPOINT_ROOT = calculateEndpointRoot('/rest/');
export const WEB_SOCKET_ROOT = calculateWebSocketRoot('/ws/');
export const EVENT_SOURCE_ROOT = calculateEndpointRoot('/es/');
export const API_ENDPOINT_ROOT = calculateEndpointRoot('/api/');
function calculateEndpointRoot(endpointPath: string) {
const httpRoot = process.env.REACT_APP_HTTP_ROOT;
if (httpRoot) {
return httpRoot + endpointPath;
}
const location = window.location;
return location.protocol + '//' + location.host + endpointPath;
}
function calculateWebSocketRoot(webSocketPath: string) {
const webSocketRoot = process.env.REACT_APP_WEB_SOCKET_ROOT;
if (webSocketRoot) {
return webSocketRoot + webSocketPath;
}
const location = window.location;
const webProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
return webProtocol + '//' + location.host + webSocketPath;
}

16
interface/src/api/ap.ts Normal file
View File

@@ -0,0 +1,16 @@
import { AxiosPromise } from 'axios';
import { APSettings, APStatus } from '../types';
import { AXIOS } from './endpoints';
export function readAPStatus(): AxiosPromise<APStatus> {
return AXIOS.get('/apStatus');
}
export function readAPSettings(): AxiosPromise<APSettings> {
return AXIOS.get('/apSettings');
}
export function updateAPSettings(apSettings: APSettings): AxiosPromise<APSettings> {
return AXIOS.post('/apSettings', apSettings);
}

View File

@@ -0,0 +1,64 @@
import { AxiosPromise } from 'axios';
import * as H from 'history';
import jwtDecode from 'jwt-decode';
import { Path } from 'react-router-dom';
import { Features, Me, SignInRequest, SignInResponse } from '../types';
import { ACCESS_TOKEN, AXIOS } from './endpoints';
import { PROJECT_PATH } from './env';
export const SIGN_IN_PATHNAME = 'loginPathname';
export const SIGN_IN_SEARCH = 'loginSearch';
export const getDefaultRoute = (features: Features) => (features.project ? `/${PROJECT_PATH}` : '/wifi');
export function verifyAuthorization(): AxiosPromise<void> {
return AXIOS.get('/verifyAuthorization');
}
export function signIn(request: SignInRequest): AxiosPromise<SignInResponse> {
return AXIOS.post('/signIn', request);
}
/**
* Fallback to sessionStorage if localStorage is absent. WebView may not have local storage enabled.
*/
export function getStorage() {
return localStorage || sessionStorage;
}
export function storeLoginRedirect(location?: H.Location) {
if (location) {
getStorage().setItem(SIGN_IN_PATHNAME, location.pathname);
getStorage().setItem(SIGN_IN_SEARCH, location.search);
}
}
export function clearLoginRedirect() {
getStorage().removeItem(SIGN_IN_PATHNAME);
getStorage().removeItem(SIGN_IN_SEARCH);
}
export function fetchLoginRedirect(features: Features): Partial<Path> {
const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME);
const signInSearch = getStorage().getItem(SIGN_IN_SEARCH);
clearLoginRedirect();
return {
pathname: signInPathname || getDefaultRoute(features),
search: (signInPathname && signInSearch) || undefined
};
}
export const clearAccessToken = () => localStorage.removeItem(ACCESS_TOKEN);
export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken) as Me;
export function addAccessTokenParameter(url: string) {
const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (!accessToken) {
return url;
}
const parsedUrl = new URL(url);
parsedUrl.searchParams.set(ACCESS_TOKEN, accessToken);
return parsedUrl.toString();
}

View File

@@ -0,0 +1,105 @@
import axios, { AxiosPromise, CancelToken } from 'axios';
import { decode } from '@msgpack/msgpack';
export const WS_BASE_URL = '/ws/';
export const API_BASE_URL = '/rest/';
export const ES_BASE_URL = '/es/';
export const EMSESP_API_BASE_URL = '/api/';
export const ACCESS_TOKEN = 'access_token';
export const WEB_SOCKET_ROOT = calculateWebSocketRoot(WS_BASE_URL);
export const EVENT_SOURCE_ROOT = calculateEventSourceRoot(ES_BASE_URL);
export const AXIOS = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
},
transformRequest: [
(data, headers) => {
if (headers) {
if (localStorage.getItem(ACCESS_TOKEN)) {
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
}
if (headers['Content-Type'] !== 'application/json') {
return data;
}
}
return JSON.stringify(data);
}
]
});
export const AXIOS_API = axios.create({
baseURL: EMSESP_API_BASE_URL,
headers: {
'Content-Type': 'application/json'
},
transformRequest: [
(data, headers) => {
if (headers) {
if (localStorage.getItem(ACCESS_TOKEN)) {
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
}
if (headers['Content-Type'] !== 'application/json') {
return data;
}
}
return JSON.stringify(data);
}
]
});
export const AXIOS_BIN = axios.create({
baseURL: API_BASE_URL,
headers: {
'Content-Type': 'application/json'
},
responseType: 'arraybuffer',
transformRequest: [
(data, headers) => {
if (headers) {
if (localStorage.getItem(ACCESS_TOKEN)) {
headers.Authorization = 'Bearer ' + localStorage.getItem(ACCESS_TOKEN);
}
if (headers['Content-Type'] !== 'application/json') {
return data;
}
}
return JSON.stringify(data);
}
],
transformResponse: [
(data) => {
return decode(data);
}
]
});
function calculateWebSocketRoot(webSocketPath: string) {
const location = window.location;
const webProtocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
return webProtocol + '//' + location.host + webSocketPath;
}
function calculateEventSourceRoot(endpointPath: string) {
const location = window.location;
return location.protocol + '//' + location.host + endpointPath;
}
export interface FileUploadConfig {
cancelToken?: CancelToken;
onUploadProgress?: (progressEvent: ProgressEvent) => void;
}
export const uploadFile = (url: string, file: File, config?: FileUploadConfig): AxiosPromise<void> => {
const formData = new FormData();
formData.append('file', file);
return AXIOS.post(url, formData, {
headers: {
'Content-Type': 'multipart/form-data'
},
...(config || {})
});
};

2
interface/src/api/env.ts Normal file
View File

@@ -0,0 +1,2 @@
export const PROJECT_NAME = process.env.REACT_APP_PROJECT_NAME || 'EMS-ESP';
export const PROJECT_PATH = process.env.REACT_APP_PROJECT_PATH || 'project';

View File

@@ -0,0 +1,9 @@
import { AxiosPromise } from 'axios';
import { Features } from '../types';
import { AXIOS } from './endpoints';
export function readFeatures(): AxiosPromise<Features> {
return AXIOS.get('/features');
}

View File

@@ -1,2 +0,0 @@
export * from './Env';
export * from './Endpoints';

16
interface/src/api/mqtt.ts Normal file
View File

@@ -0,0 +1,16 @@
import { AxiosPromise } from 'axios';
import { MqttSettings, MqttStatus } from '../types';
import { AXIOS } from './endpoints';
export function readMqttStatus(): AxiosPromise<MqttStatus> {
return AXIOS.get('/mqttStatus');
}
export function readMqttSettings(): AxiosPromise<MqttSettings> {
return AXIOS.get('/mqttSettings');
}
export function updateMqttSettings(ntpSettings: MqttSettings): AxiosPromise<MqttSettings> {
return AXIOS.post('/mqttSettings', ntpSettings);
}

View File

@@ -0,0 +1,25 @@
import { AxiosPromise } from 'axios';
import { WiFiNetworkList, NetworkSettings, NetworkStatus } from '../types';
import { AXIOS } from './endpoints';
export function readNetworkStatus(): AxiosPromise<NetworkStatus> {
return AXIOS.get('/networkStatus');
}
export function scanNetworks(): AxiosPromise<void> {
return AXIOS.get('/scanNetworks');
}
export function listNetworks(): AxiosPromise<WiFiNetworkList> {
return AXIOS.get('/listNetworks');
}
export function readNetworkSettings(): AxiosPromise<NetworkSettings> {
return AXIOS.get('/networkSettings');
}
export function updateNetworkSettings(wifiSettings: NetworkSettings): AxiosPromise<NetworkSettings> {
return AXIOS.post('/networkSettings', wifiSettings);
}

20
interface/src/api/ntp.ts Normal file
View File

@@ -0,0 +1,20 @@
import { AxiosPromise } from 'axios';
import { NTPSettings, NTPStatus, Time } from '../types';
import { AXIOS } from './endpoints';
export function readNTPStatus(): AxiosPromise<NTPStatus> {
return AXIOS.get('/ntpStatus');
}
export function readNTPSettings(): AxiosPromise<NTPSettings> {
return AXIOS.get('/ntpSettings');
}
export function updateNTPSettings(ntpSettings: NTPSettings): AxiosPromise<NTPSettings> {
return AXIOS.post('/ntpSettings', ntpSettings);
}
export function updateTime(time: Time): AxiosPromise<Time> {
return AXIOS.post('/time', time);
}

View File

@@ -0,0 +1,17 @@
import { AxiosPromise } from 'axios';
import { SecuritySettings, Token } from '../types';
import { AXIOS } from './endpoints';
export function readSecuritySettings(): AxiosPromise<SecuritySettings> {
return AXIOS.get('/securitySettings');
}
export function updateSecuritySettings(securitySettings: SecuritySettings): AxiosPromise<SecuritySettings> {
return AXIOS.post('/securitySettings', securitySettings);
}
export function generateToken(username?: string): AxiosPromise<Token> {
return AXIOS.get('/generateToken', { params: { username } });
}

View File

@@ -0,0 +1,40 @@
import { AxiosPromise } from 'axios';
import { OTASettings, SystemStatus, LogSettings, LogEntries } from '../types';
import { AXIOS, AXIOS_BIN, FileUploadConfig, uploadFile } from './endpoints';
export function readSystemStatus(timeout?: number): AxiosPromise<SystemStatus> {
return AXIOS.get('/systemStatus', { timeout });
}
export function restart(): AxiosPromise<void> {
return AXIOS.post('/restart');
}
export function factoryReset(): AxiosPromise<void> {
return AXIOS.post('/factoryReset');
}
export function readOTASettings(): AxiosPromise<OTASettings> {
return AXIOS.get('/otaSettings');
}
export function updateOTASettings(otaSettings: OTASettings): AxiosPromise<OTASettings> {
return AXIOS.post('/otaSettings', otaSettings);
}
export const uploadFirmware = (file: File, config?: FileUploadConfig): AxiosPromise<void> =>
uploadFile('/uploadFirmware', file, config);
export function readLogSettings(): AxiosPromise<LogSettings> {
return AXIOS.get('/logSettings');
}
export function updateLogSettings(logSettings: LogSettings): AxiosPromise<LogSettings> {
return AXIOS.post('/logSettings', logSettings);
}
export function readLogEntries(): AxiosPromise<LogEntries> {
return AXIOS_BIN.get('/fetchLog');
}

View File

@@ -1,56 +0,0 @@
import * as React from 'react';
import {
Redirect,
Route,
RouteProps,
RouteComponentProps
} from 'react-router-dom';
import { withSnackbar, WithSnackbarProps } from 'notistack';
import * as Authentication from './Authentication';
import {
withAuthenticationContext,
AuthenticationContextProps,
AuthenticatedContext,
AuthenticatedContextValue
} from './AuthenticationContext';
interface AuthenticatedRouteProps
extends RouteProps,
WithSnackbarProps,
AuthenticationContextProps {
component:
| React.ComponentType<RouteComponentProps<any>>
| React.ComponentType<any>;
}
type RenderComponent = (props: RouteComponentProps<any>) => React.ReactNode;
export class AuthenticatedRoute extends React.Component<AuthenticatedRouteProps> {
render() {
const {
enqueueSnackbar,
authenticationContext,
component: Component,
...rest
} = this.props;
const { location } = this.props;
const renderComponent: RenderComponent = (props) => {
if (authenticationContext.me) {
return (
<AuthenticatedContext.Provider
value={authenticationContext as AuthenticatedContextValue}
>
<Component {...props} />
</AuthenticatedContext.Provider>
);
}
Authentication.storeLoginRedirect(location);
enqueueSnackbar('Please sign in to continue', { variant: 'info' });
return <Redirect to="/" />;
};
return <Route {...rest} render={renderComponent} />;
}
}
export default withSnackbar(withAuthenticationContext(AuthenticatedRoute));

View File

@@ -1,129 +0,0 @@
import * as H from 'history';
import history from '../history';
import { Features } from '../features/types';
import { getDefaultRoute } from '../AppRouting';
export const ACCESS_TOKEN = 'access_token';
export const SIGN_IN_PATHNAME = 'signInPathname';
export const SIGN_IN_SEARCH = 'signInSearch';
/**
* Fallback to sessionStorage if localStorage is absent. WebView may not have local storage enabled.
*/
export function getStorage() {
return localStorage || sessionStorage;
}
export function storeLoginRedirect(location?: H.Location) {
if (location) {
getStorage().setItem(SIGN_IN_PATHNAME, location.pathname);
getStorage().setItem(SIGN_IN_SEARCH, location.search);
}
}
export function clearLoginRedirect() {
getStorage().removeItem(SIGN_IN_PATHNAME);
getStorage().removeItem(SIGN_IN_SEARCH);
}
export function fetchLoginRedirect(
features: Features
): H.LocationDescriptorObject {
const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME);
const signInSearch = getStorage().getItem(SIGN_IN_SEARCH);
clearLoginRedirect();
return {
pathname: signInPathname || getDefaultRoute(features),
search: (signInPathname && signInSearch) || undefined
};
}
/**
* Wraps the normal fetch routine with one with provides the access token if present.
*/
export function authorizedFetch(
url: RequestInfo,
params?: RequestInit
): Promise<Response> {
const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (accessToken) {
params = params || {};
params.credentials = 'include';
params.headers = {
...params.headers,
Authorization: 'Bearer ' + accessToken
};
}
return fetch(url, params);
}
/**
* fetch() does not yet support upload progress, this wrapper allows us to configure the xhr request
* for a single file upload and takes care of adding the Authorization header and redirecting on
* authorization errors as we do for normal fetch operations.
*/
export function redirectingAuthorizedUpload(
xhr: XMLHttpRequest,
url: string,
file: File,
onProgress: (event: ProgressEvent<EventTarget>) => void
): Promise<void> {
return new Promise((resolve, reject) => {
xhr.open('POST', url, true);
const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (accessToken) {
xhr.withCredentials = true;
xhr.setRequestHeader('Authorization', 'Bearer ' + accessToken);
}
xhr.upload.onprogress = onProgress;
xhr.onload = function () {
if (xhr.status === 401 || xhr.status === 403) {
history.push('/unauthorized');
} else {
resolve();
}
};
xhr.onerror = function () {
reject(new DOMException('Error', 'UploadError'));
};
xhr.onabort = function () {
reject(new DOMException('Aborted', 'AbortError'));
};
const formData = new FormData();
formData.append('file', file);
xhr.send(formData);
});
}
/**
* Wraps the normal fetch routine which redirects on 401 response.
*/
export function redirectingAuthorizedFetch(
url: RequestInfo,
params?: RequestInit
): Promise<Response> {
return new Promise<Response>((resolve, reject) => {
authorizedFetch(url, params)
.then((response) => {
if (response.status === 401 || response.status === 403) {
history.push('/unauthorized');
} else {
resolve(response);
}
})
.catch((error) => {
reject(error);
});
});
}
export function addAccessTokenParameter(url: string) {
const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (!accessToken) {
return url;
}
const parsedUrl = new URL(url);
parsedUrl.searchParams.set(ACCESS_TOKEN, accessToken);
return parsedUrl.toString();
}

View File

@@ -1,77 +0,0 @@
import * as React from 'react';
export interface Me {
username: string;
admin: boolean;
}
export interface AuthenticationContextValue {
refresh: () => void;
signIn: (accessToken: string) => void;
signOut: () => void;
me?: Me;
}
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
export const AuthenticationContext = React.createContext(
AuthenticationContextDefaultValue
);
export interface AuthenticationContextProps {
authenticationContext: AuthenticationContextValue;
}
export function withAuthenticationContext<T extends AuthenticationContextProps>(
Component: React.ComponentType<T>
) {
return class extends React.Component<
Omit<T, keyof AuthenticationContextProps>
> {
render() {
return (
<AuthenticationContext.Consumer>
{(authenticationContext) => (
<Component
{...(this.props as T)}
authenticationContext={authenticationContext}
/>
)}
</AuthenticationContext.Consumer>
);
}
};
}
export interface AuthenticatedContextValue extends AuthenticationContextValue {
me: Me;
}
const AuthenticatedContextDefaultValue = {} as AuthenticatedContextValue;
export const AuthenticatedContext = React.createContext(
AuthenticatedContextDefaultValue
);
export interface AuthenticatedContextProps {
authenticatedContext: AuthenticatedContextValue;
}
export function withAuthenticatedContext<T extends AuthenticatedContextProps>(
Component: React.ComponentType<T>
) {
return class extends React.Component<
Omit<T, keyof AuthenticatedContextProps>
> {
render() {
return (
<AuthenticatedContext.Consumer>
{(authenticatedContext) => (
<Component
{...(this.props as T)}
authenticatedContext={authenticatedContext}
/>
)}
</AuthenticatedContext.Consumer>
);
}
};
}

View File

@@ -1,135 +0,0 @@
import * as React from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack';
import jwtDecode from 'jwt-decode';
import history from '../history';
import { VERIFY_AUTHORIZATION_ENDPOINT } from '../api';
import { ACCESS_TOKEN, authorizedFetch, getStorage } from './Authentication';
import {
AuthenticationContext,
AuthenticationContextValue,
Me
} from './AuthenticationContext';
import FullScreenLoading from '../components/FullScreenLoading';
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
export const decodeMeJWT = (accessToken: string): Me =>
jwtDecode(accessToken) as Me;
interface AuthenticationWrapperState {
context: AuthenticationContextValue;
initialized: boolean;
}
type AuthenticationWrapperProps = WithSnackbarProps & WithFeaturesProps;
class AuthenticationWrapper extends React.Component<
AuthenticationWrapperProps,
AuthenticationWrapperState
> {
constructor(props: AuthenticationWrapperProps) {
super(props);
this.state = {
context: {
refresh: this.refresh,
signIn: this.signIn,
signOut: this.signOut
},
initialized: false
};
}
componentDidMount() {
this.refresh();
}
render() {
return (
<React.Fragment>
{this.state.initialized
? this.renderContent()
: this.renderContentLoading()}
</React.Fragment>
);
}
renderContent() {
return (
<AuthenticationContext.Provider value={this.state.context}>
{this.props.children}
</AuthenticationContext.Provider>
);
}
renderContentLoading() {
return <FullScreenLoading />;
}
refresh = () => {
// commented out, always need security - proddy
// if (!this.props.features.security) {
// this.setState({ initialized: true, context: { ...this.state.context, me: { admin: true, username: "admin" } } });
// return;
// }
const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (accessToken) {
authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT)
.then((response) => {
const me =
response.status === 200 ? decodeMeJWT(accessToken) : undefined;
this.setState({
initialized: true,
context: { ...this.state.context, me }
});
})
.catch((error) => {
this.setState({
initialized: true,
context: { ...this.state.context, me: undefined }
});
this.props.enqueueSnackbar(
'Error verifying authorization: ' + error.message,
{
variant: 'error'
}
);
});
} else {
this.setState({
initialized: true,
context: { ...this.state.context, me: undefined }
});
}
};
signIn = (accessToken: string) => {
try {
getStorage().setItem(ACCESS_TOKEN, accessToken);
const me: Me = decodeMeJWT(accessToken);
this.setState({ context: { ...this.state.context, me } });
this.props.enqueueSnackbar(`Logged in as ${me.username}`, {
variant: 'success'
});
} catch (err) {
this.setState({
initialized: true,
context: { ...this.state.context, me: undefined }
});
throw new Error('Failed to parse JWT ' + err.message);
}
};
signOut = () => {
getStorage().removeItem(ACCESS_TOKEN);
this.setState({
context: {
...this.state.context,
me: undefined
}
});
this.props.enqueueSnackbar('You have signed out', { variant: 'success' });
history.push('/');
};
}
export default withFeatures(withSnackbar(AuthenticationWrapper));

View File

@@ -1,35 +0,0 @@
import * as React from 'react';
import {
Redirect,
Route,
RouteProps,
RouteComponentProps
} from 'react-router-dom';
import {
withAuthenticationContext,
AuthenticationContextProps
} from './AuthenticationContext';
import * as Authentication from './Authentication';
import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext';
interface UnauthenticatedRouteProps
extends RouteProps,
AuthenticationContextProps,
WithFeaturesProps {
component:
| React.ComponentType<RouteComponentProps<any>>
| React.ComponentType<any>;
}
class UnauthenticatedRoute extends Route<UnauthenticatedRouteProps> {
public render() {
const { authenticationContext, features, ...rest } = this.props;
if (authenticationContext.me) {
return <Redirect to={Authentication.fetchLoginRedirect(features)} />;
}
return <Route {...rest} />;
}
}
export default withFeatures(withAuthenticationContext(UnauthenticatedRoute));

View File

@@ -1,6 +0,0 @@
export { default as AuthenticatedRoute } from './AuthenticatedRoute';
export { default as AuthenticationWrapper } from './AuthenticationWrapper';
export { default as UnauthenticatedRoute } from './UnauthenticatedRoute';
export * from './Authentication';
export * from './AuthenticationContext';

View File

@@ -1,59 +0,0 @@
import React, { FC } from 'react';
import { makeStyles } from '@material-ui/styles';
import { Paper, Typography, Box, CssBaseline } from '@material-ui/core';
import WarningIcon from '@material-ui/icons/Warning';
const styles = makeStyles({
siteErrorPage: {
display: 'flex',
height: '100vh',
justifyContent: 'center',
flexDirection: 'column'
},
siteErrorPagePanel: {
textAlign: 'center',
padding: '280px 0 40px 0',
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: 'no-repeat',
backgroundPosition: '50% 40px',
backgroundSize: '200px auto',
width: '100%'
}
});
interface ApplicationErrorProps {
error?: string;
}
const ApplicationError: FC<ApplicationErrorProps> = ({ error }) => {
const classes = styles();
return (
<div className={classes.siteErrorPage}>
<CssBaseline />
<Paper className={classes.siteErrorPagePanel} elevation={10}>
<Box
display="flex"
flexDirection="row"
justifyContent="center"
alignItems="center"
mb={2}
>
<WarningIcon fontSize="large" color="error" />
<Box ml={2}>
<Typography variant="h4">Application error</Typography>
</Box>
</Box>
<Typography variant="subtitle1" gutterBottom>
Failed to configure the application, please refresh to try again.
</Typography>
{error && (
<Typography variant="subtitle2" gutterBottom>
Error: {error}
</Typography>
)}
</Paper>
</div>
);
};
export default ApplicationError;

View File

@@ -0,0 +1,26 @@
import { FC } from 'react';
import { Box, BoxProps } from '@mui/material';
const ButtonRow: FC<BoxProps> = ({ children, ...rest }) => {
return (
<Box
sx={{
'& button, & a, & .MuiCard-root': {
mt: 2,
mx: 0.6,
'&:last-child': {
mr: 0
},
'&:first-of-type': {
ml: 0
}
}
}}
{...rest}
>
{children}
</Box>
);
};
export default ButtonRow;

View File

@@ -1,11 +0,0 @@
import { Button, styled } from '@material-ui/core';
const ErrorButton = styled(Button)(({ theme }) => ({
color: theme.palette.getContrastText(theme.palette.error.main),
backgroundColor: theme.palette.error.main,
'&:hover': {
backgroundColor: theme.palette.error.dark
}
}));
export default ErrorButton;

View File

@@ -1,7 +0,0 @@
import { styled, Box } from '@material-ui/core';
const FormActions = styled(Box)(({ theme }) => ({
marginTop: theme.spacing(1)
}));
export default FormActions;

View File

@@ -1,13 +0,0 @@
import { Button, styled } from '@material-ui/core';
const FormButton = styled(Button)(({ theme }) => ({
margin: theme.spacing(0, 1),
'&:last-child': {
marginRight: 0
},
'&:first-child': {
marginLeft: 0
}
}));
export default FormButton;

View File

@@ -1,56 +0,0 @@
import { FC } from 'react';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import { Button, LinearProgress, Typography } from '@material-ui/core';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
loadingSettings: {
margin: theme.spacing(0.5)
},
loadingSettingsDetails: {
margin: theme.spacing(4),
textAlign: 'center'
},
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2)
}
})
);
interface FormLoaderProps {
errorMessage?: string;
loadData: () => void;
}
const FormLoader: FC<FormLoaderProps> = ({ errorMessage, loadData }) => {
const classes = useStyles();
if (errorMessage) {
return (
<div className={classes.loadingSettings}>
<Typography variant="h6" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button
variant="contained"
color="secondary"
className={classes.button}
onClick={loadData}
>
Retry
</Button>
</div>
);
}
return (
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails} />
<Typography variant="h6" className={classes.loadingSettingsDetails}>
Loading&hellip;
</Typography>
</div>
);
};
export default FormLoader;

View File

@@ -1,32 +0,0 @@
import React from 'react';
import CircularProgress from '@material-ui/core/CircularProgress';
import { Typography, Theme } from '@material-ui/core';
import { makeStyles, createStyles } from '@material-ui/styles';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
fullScreenLoading: {
padding: theme.spacing(2),
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
flexDirection: 'column'
},
progress: {
margin: theme.spacing(4)
}
})
);
const FullScreenLoading = () => {
const classes = useStyles();
return (
<div className={classes.fullScreenLoading}>
<CircularProgress className={classes.progress} size={100} />
<Typography variant="h4">Loading&hellip;</Typography>
</div>
);
};
export default FullScreenLoading;

View File

@@ -1,19 +0,0 @@
import { Avatar, makeStyles } from '@material-ui/core';
import { FC } from 'react';
interface HighlightAvatarProps {
color: string;
}
const useStyles = makeStyles({
root: (props: HighlightAvatarProps) => ({
backgroundColor: props.color
})
});
const HighlightAvatar: FC<HighlightAvatarProps> = (props) => {
const classes = useStyles(props);
return <Avatar className={classes.root}>{props.children}</Avatar>;
};
export default HighlightAvatar;

View File

@@ -1,390 +0,0 @@
import React, { RefObject, Fragment } from 'react';
import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
import {
Drawer,
AppBar,
Toolbar,
Avatar,
Divider,
Button,
Box,
IconButton
} from '@material-ui/core';
import {
ClickAwayListener,
Popper,
Hidden,
Typography
} from '@material-ui/core';
import {
List,
ListItem,
ListItemIcon,
ListItemText,
ListItemAvatar
} from '@material-ui/core';
import { Card, CardContent, CardActions } from '@material-ui/core';
import {
withStyles,
createStyles,
Theme,
WithTheme,
WithStyles,
withTheme
} from '@material-ui/core/styles';
import SettingsEthernetIcon from '@material-ui/icons/SettingsEthernet';
import SettingsIcon from '@material-ui/icons/Settings';
import AccessTimeIcon from '@material-ui/icons/AccessTime';
import AccountCircleIcon from '@material-ui/icons/AccountCircle';
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import LockIcon from '@material-ui/icons/Lock';
import MenuIcon from '@material-ui/icons/Menu';
import ProjectMenu from '../project/ProjectMenu';
import { PROJECT_NAME } from '../api';
import {
withAuthenticatedContext,
AuthenticatedContextProps
} from '../authentication';
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
const drawerWidth = 290;
const styles = (theme: Theme) =>
createStyles({
root: {
display: 'flex'
},
drawer: {
[theme.breakpoints.up('md')]: {
width: drawerWidth,
flexShrink: 0
}
},
title: {
flexGrow: 1
},
appBar: {
marginLeft: drawerWidth,
[theme.breakpoints.up('md')]: {
width: `calc(100% - ${drawerWidth}px)`
}
},
toolbarImage: {
[theme.breakpoints.up('xs')]: {
height: 24,
marginRight: theme.spacing(2)
},
[theme.breakpoints.up('sm')]: {
height: 36,
marginRight: theme.spacing(3)
}
},
menuButton: {
marginRight: theme.spacing(2),
[theme.breakpoints.up('md')]: {
display: 'none'
}
},
toolbar: theme.mixins.toolbar,
drawerPaper: {
width: drawerWidth
},
content: {
flexGrow: 1
},
authMenu: {
zIndex: theme.zIndex.tooltip,
maxWidth: 400
},
authMenuActions: {
padding: theme.spacing(2),
'& > * + *': {
marginLeft: theme.spacing(2)
}
}
});
interface MenuAppBarState {
mobileOpen: boolean;
authMenuOpen: boolean;
}
interface MenuAppBarProps
extends WithFeaturesProps,
AuthenticatedContextProps,
WithTheme,
WithStyles<typeof styles>,
RouteComponentProps {
sectionTitle: string;
}
class MenuAppBar extends React.Component<MenuAppBarProps, MenuAppBarState> {
constructor(props: MenuAppBarProps) {
super(props);
this.state = {
mobileOpen: false,
authMenuOpen: false
};
}
anchorRef: RefObject<HTMLButtonElement> = React.createRef();
handleToggle = () => {
this.setState({ authMenuOpen: !this.state.authMenuOpen });
};
handleClose = (event: React.MouseEvent<Document>) => {
if (
this.anchorRef.current &&
this.anchorRef.current.contains(event.currentTarget)
) {
return;
}
this.setState({ authMenuOpen: false });
};
handleDrawerToggle = () => {
this.setState({ mobileOpen: !this.state.mobileOpen });
};
render() {
const {
classes,
theme,
children,
sectionTitle,
authenticatedContext,
features
} = this.props;
const { mobileOpen, authMenuOpen } = this.state;
const path = this.props.match.url;
const drawer = (
<div>
<Toolbar>
<Box display="flex">
<img
src="/app/icon.png"
className={classes.toolbarImage}
alt={PROJECT_NAME}
/>
</Box>
<Typography variant="h6" color="textPrimary">
{PROJECT_NAME}
</Typography>
<Divider absolute />
</Toolbar>
{features.project && (
<Fragment>
<ProjectMenu />
<Divider />
</Fragment>
)}
<List>
<ListItem
to="/network/"
selected={path.startsWith('/network/')}
button
component={Link}
>
<ListItemIcon>
<SettingsEthernetIcon />
</ListItemIcon>
<ListItemText primary="Network Connection" />
</ListItem>
<ListItem
to="/ap/"
selected={path.startsWith('/ap/')}
button
component={Link}
>
<ListItemIcon>
<SettingsInputAntennaIcon />
</ListItemIcon>
<ListItemText primary="Access Point" />
</ListItem>
{features.ntp && (
<ListItem
to="/ntp/"
selected={path.startsWith('/ntp/')}
button
component={Link}
>
<ListItemIcon>
<AccessTimeIcon />
</ListItemIcon>
<ListItemText primary="Network Time" />
</ListItem>
)}
{features.mqtt && (
<ListItem
to="/mqtt/"
selected={path.startsWith('/mqtt/')}
button
component={Link}
>
<ListItemIcon>
<DeviceHubIcon />
</ListItemIcon>
<ListItemText primary="MQTT" />
</ListItem>
)}
{features.security && (
<ListItem
to="/security/"
selected={path.startsWith('/security/')}
button
component={Link}
disabled={!authenticatedContext.me.admin}
>
<ListItemIcon>
<LockIcon />
</ListItemIcon>
<ListItemText primary="Security" />
</ListItem>
)}
<ListItem
to="/system/"
selected={path.startsWith('/system/')}
button
component={Link}
>
<ListItemIcon>
<SettingsIcon />
</ListItemIcon>
<ListItemText primary="System" />
</ListItem>
</List>
</div>
);
const userMenu = (
<div>
<IconButton
ref={this.anchorRef}
aria-owns={authMenuOpen ? 'menu-list-grow' : undefined}
aria-haspopup="true"
onClick={this.handleToggle}
color="inherit"
>
<AccountCircleIcon />
</IconButton>
<Popper
open={authMenuOpen}
anchorEl={this.anchorRef.current}
transition
className={classes.authMenu}
>
<ClickAwayListener onClickAway={this.handleClose}>
<Card id="menu-list-grow">
<CardContent>
<List disablePadding>
<ListItem disableGutters>
<ListItemAvatar>
<Avatar>
<AccountCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
'Signed in as: ' + authenticatedContext.me.username
}
secondary={
authenticatedContext.me.admin ? 'Admin User' : undefined
}
/>
</ListItem>
</List>
</CardContent>
<Divider />
<CardActions className={classes.authMenuActions}>
<Button
variant="contained"
fullWidth
color="primary"
onClick={authenticatedContext.signOut}
>
Sign Out
</Button>
</CardActions>
</Card>
</ClickAwayListener>
</Popper>
</div>
);
return (
<div className={classes.root}>
<AppBar position="fixed" className={classes.appBar} elevation={0}>
<Toolbar>
<IconButton
color="inherit"
aria-label="Open drawer"
edge="start"
onClick={this.handleDrawerToggle}
className={classes.menuButton}
>
<MenuIcon />
</IconButton>
<Typography
variant="h6"
color="inherit"
noWrap
className={classes.title}
>
{sectionTitle}
</Typography>
{features.security && userMenu}
</Toolbar>
</AppBar>
<nav className={classes.drawer}>
<Hidden mdUp implementation="css">
<Drawer
variant="temporary"
anchor={theme.direction === 'rtl' ? 'right' : 'left'}
open={mobileOpen}
onClose={this.handleDrawerToggle}
classes={{
paper: classes.drawerPaper
}}
ModalProps={{
keepMounted: true
}}
>
{drawer}
</Drawer>
</Hidden>
<Hidden smDown implementation="css">
<Drawer
classes={{
paper: classes.drawerPaper
}}
variant="permanent"
open
>
{drawer}
</Drawer>
</Hidden>
</nav>
<main className={classes.content}>
<div className={classes.toolbar} />
{children}
</main>
</div>
);
}
}
export default withRouter(
withTheme(
withFeatures(withAuthenticatedContext(withStyles(styles)(MenuAppBar)))
)
);

View File

@@ -0,0 +1,48 @@
import { FC } from 'react';
import { Box, BoxProps, SvgIconProps, Theme, Typography, useTheme } from '@mui/material';
import CheckCircleOutlineOutlinedIcon from '@mui/icons-material/CheckCircleOutlineOutlined';
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined';
import ReportProblemOutlinedIcon from '@mui/icons-material/ReportProblemOutlined';
import ErrorIcon from '@mui/icons-material/Error';
type MessageBoxLevel = 'warning' | 'success' | 'info' | 'error';
export interface MessageBoxProps extends BoxProps {
level: MessageBoxLevel;
message: string;
}
const LEVEL_ICONS: { [type in MessageBoxLevel]: React.ComponentType<SvgIconProps> } = {
success: CheckCircleOutlineOutlinedIcon,
info: InfoOutlinedIcon,
warning: ReportProblemOutlinedIcon,
error: ErrorIcon
};
const LEVEL_BACKGROUNDS: { [type in MessageBoxLevel]: (theme: Theme) => string } = {
success: (theme: Theme) => theme.palette.success.dark,
info: (theme: Theme) => theme.palette.info.main,
warning: (theme: Theme) => theme.palette.warning.dark,
error: (theme: Theme) => theme.palette.error.dark
};
const MessageBox: FC<MessageBoxProps> = ({ level, message, sx, children, ...rest }) => {
const theme = useTheme();
const Icon = LEVEL_ICONS[level];
const backgroundColor = LEVEL_BACKGROUNDS[level](theme);
// const color = theme.palette.getContrastText(backgroundColor);
const color = 'white';
return (
<Box p={2} display="flex" alignItems="center" borderRadius={1} sx={{ backgroundColor, color, ...sx }} {...rest}>
<Icon />
<Typography sx={{ ml: 2, flexGrow: 1 }} variant="body1">
{message}
</Typography>
{children}
</Box>
);
};
export default MessageBox;

View File

@@ -1,64 +0,0 @@
import React from 'react';
import {
TextValidator,
ValidatorComponentProps
} from 'react-material-ui-form-validator';
import { withStyles, WithStyles, createStyles } from '@material-ui/core/styles';
import { InputAdornment, IconButton } from '@material-ui/core';
import { Visibility, VisibilityOff } from '@material-ui/icons';
const styles = createStyles({
input: {
'&::-ms-reveal': {
display: 'none'
}
}
});
type PasswordValidatorProps = WithStyles<typeof styles> &
Exclude<ValidatorComponentProps, 'type' | 'InputProps'>;
interface PasswordValidatorState {
showPassword: boolean;
}
class PasswordValidator extends React.Component<
PasswordValidatorProps,
PasswordValidatorState
> {
state = {
showPassword: false
};
toggleShowPassword = () => {
this.setState({
showPassword: !this.state.showPassword
});
};
render() {
const { classes, ...rest } = this.props;
return (
<TextValidator
{...rest}
type={this.state.showPassword ? 'text' : 'password'}
InputProps={{
classes,
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="Toggle password visibility"
onClick={this.toggleShowPassword}
>
{this.state.showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
)
}}
/>
);
}
}
export default withStyles(styles)(PasswordValidator);

View File

@@ -1,141 +0,0 @@
import React from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack';
import { redirectingAuthorizedFetch } from '../authentication';
export interface RestControllerProps<D> extends WithSnackbarProps {
handleValueChange: (
name: keyof D
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
setData: (data: D, callback?: () => void) => void;
saveData: () => void;
loadData: () => void;
data?: D;
loading: boolean;
errorMessage?: string;
}
export const extractEventValue = (
event: React.ChangeEvent<HTMLInputElement>
) => {
switch (event.target.type) {
case 'number':
return event.target.valueAsNumber;
case 'checkbox':
return event.target.checked;
default:
return event.target.value;
}
};
interface RestControllerState<D> {
data?: D;
loading: boolean;
errorMessage?: string;
}
export function restController<D, P extends RestControllerProps<D>>(
endpointUrl: string,
RestController: React.ComponentType<P & RestControllerProps<D>>
) {
return withSnackbar(
class extends React.Component<
Omit<P, keyof RestControllerProps<D>> & WithSnackbarProps,
RestControllerState<D>
> {
state: RestControllerState<D> = {
data: undefined,
loading: false,
errorMessage: undefined
};
setData = (data: D, callback?: () => void) => {
this.setState(
{
data,
loading: false,
errorMessage: undefined
},
callback
);
};
loadData = () => {
this.setState({
data: undefined,
loading: true,
errorMessage: undefined
});
redirectingAuthorizedFetch(endpointUrl)
.then((response) => {
if (response.status === 200) {
return response.json();
}
throw Error('Invalid status code: ' + response.status);
})
.then((json) => {
this.setState({ data: json, loading: false });
})
.catch((error) => {
const errorMessage = error.message || 'Unknown error';
this.props.enqueueSnackbar('Problem fetching: ' + errorMessage, {
variant: 'error'
});
this.setState({ data: undefined, loading: false, errorMessage });
});
};
saveData = () => {
this.setState({ loading: true });
redirectingAuthorizedFetch(endpointUrl, {
method: 'POST',
body: JSON.stringify(this.state.data),
headers: {
'Content-Type': 'application/json'
}
})
.then((response) => {
if (response.status === 200) {
return response.json();
}
throw Error('Invalid status code: ' + response.status);
})
.then((json) => {
this.props.enqueueSnackbar('Update successful.', {
variant: 'success'
});
this.setState({ data: json, loading: false });
})
.catch((error) => {
const errorMessage = error.message || 'Unknown error';
this.props.enqueueSnackbar('Problem updating: ' + errorMessage, {
variant: 'error'
});
this.setState({ data: undefined, loading: false, errorMessage });
});
};
handleValueChange = (name: keyof D) => (
event: React.ChangeEvent<HTMLInputElement>
) => {
const data = { ...this.state.data!, [name]: extractEventValue(event) };
this.setState({ data });
};
render() {
return (
<RestController
{...this.state}
{...(this.props as P)}
handleValueChange={this.handleValueChange}
setData={this.setData}
saveData={this.saveData}
loadData={this.loadData}
/>
);
}
}
);
}

View File

@@ -1,64 +0,0 @@
import React from 'react';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import { Button, LinearProgress, Typography } from '@material-ui/core';
import { RestControllerProps } from '.';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
loadingSettings: {
margin: theme.spacing(0.5)
},
loadingSettingsDetails: {
margin: theme.spacing(4),
textAlign: 'center'
},
button: {
marginRight: theme.spacing(2),
marginTop: theme.spacing(2)
}
})
);
export type RestFormProps<D> = Omit<
RestControllerProps<D>,
'loading' | 'errorMessage'
> & { data: D };
interface RestFormLoaderProps<D> extends RestControllerProps<D> {
render: (props: RestFormProps<D>) => JSX.Element;
}
export default function RestFormLoader<D>(props: RestFormLoaderProps<D>) {
const { loading, errorMessage, loadData, render, data, ...rest } = props;
const classes = useStyles();
if (loading || !data) {
return (
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails} />
<Typography variant="h6" className={classes.loadingSettingsDetails}>
Loading&hellip;
</Typography>
</div>
);
}
if (errorMessage) {
return (
<div className={classes.loadingSettings}>
<Typography variant="h6" className={classes.loadingSettingsDetails}>
{errorMessage}
</Typography>
<Button
variant="contained"
color="secondary"
className={classes.button}
onClick={loadData}
>
Retry
</Button>
</div>
);
}
return render({ ...rest, loadData, data });
}

View File

@@ -1,16 +1,6 @@
import React from 'react';
import { FC } from 'react';
import { Typography, Paper } from '@material-ui/core';
import { createStyles, Theme, makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
content: {
padding: theme.spacing(2),
margin: theme.spacing(3)
}
})
);
import { Paper, Divider } from '@mui/material';
interface SectionContentProps {
title: string;
@@ -18,14 +8,11 @@ interface SectionContentProps {
id?: string;
}
const SectionContent: React.FC<SectionContentProps> = (props) => {
const { children, title, titleGutter, id } = props;
const classes = useStyles();
const SectionContent: FC<SectionContentProps> = (props) => {
const { children, title, id } = props;
return (
<Paper id={id} className={classes.content}>
<Typography variant="h6" gutterBottom={titleGutter}>
{title}
</Typography>
<Paper id={id} sx={{ p: 2, m: 2 }}>
<Divider sx={{ pb: 2, borderColor: 'primary.main', fontSize: 20, color: 'primary.main' }}>{title}</Divider>
{children}
</Paper>
);

View File

@@ -1,128 +0,0 @@
import React, { FC, Fragment } from 'react';
import { useDropzone, DropzoneState } from 'react-dropzone';
import { makeStyles, createStyles } from '@material-ui/styles';
import CloudUploadIcon from '@material-ui/icons/CloudUpload';
import CancelIcon from '@material-ui/icons/Cancel';
import {
Theme,
Box,
Typography,
LinearProgress,
Button
} from '@material-ui/core';
interface SingleUploadStyleProps extends DropzoneState {
uploading: boolean;
}
const progressPercentage = (progress: ProgressEvent) =>
Math.round((progress.loaded * 100) / progress.total);
const getBorderColor = (theme: Theme, props: SingleUploadStyleProps) => {
if (props.isDragAccept) {
return theme.palette.success.main;
}
if (props.isDragReject) {
return theme.palette.error.main;
}
if (props.isDragActive) {
return theme.palette.info.main;
}
return theme.palette.grey[700];
};
const useStyles = makeStyles((theme: Theme) =>
createStyles({
dropzone: {
padding: theme.spacing(8, 2),
borderWidth: 2,
borderRadius: 2,
borderStyle: 'dashed',
color: theme.palette.grey[700],
transition: 'border .24s ease-in-out',
cursor: (props: SingleUploadStyleProps) =>
props.uploading ? 'default' : 'pointer',
width: '100%',
borderColor: (props: SingleUploadStyleProps) =>
getBorderColor(theme, props)
}
})
);
export interface SingleUploadProps {
onDrop: (acceptedFiles: File[]) => void;
onCancel: () => void;
accept?: string | string[];
uploading: boolean;
progress?: ProgressEvent;
}
const SingleUpload: FC<SingleUploadProps> = ({
onDrop,
onCancel,
accept,
uploading,
progress
}) => {
const dropzoneState = useDropzone({
onDrop,
accept,
disabled: uploading,
multiple: false
});
const { getRootProps, getInputProps } = dropzoneState;
const classes = useStyles({ ...dropzoneState, uploading });
const renderProgressText = () => {
if (uploading) {
if (progress?.lengthComputable) {
return `Uploading: ${progressPercentage(progress)}%`;
}
return 'Uploading\u2026';
}
return 'Drop file or click here';
};
const renderProgress = (progress?: ProgressEvent) => (
<LinearProgress
variant={
!progress || progress.lengthComputable ? 'determinate' : 'indeterminate'
}
value={
!progress
? 0
: progress.lengthComputable
? progressPercentage(progress)
: 0
}
/>
);
return (
<div {...getRootProps({ className: classes.dropzone })}>
<input {...getInputProps()} />
<Box flexDirection="column" display="flex" alignItems="center">
<CloudUploadIcon fontSize="large" />
<Typography variant="h6">{renderProgressText()}</Typography>
{uploading && (
<Fragment>
<Box width="100%" p={2}>
{renderProgress(progress)}
</Box>
<Button
startIcon={<CancelIcon />}
variant="contained"
color="secondary"
onClick={onCancel}
>
Cancel
</Button>
</Fragment>
)}
</Box>
</div>
);
};
export default SingleUpload;

View File

@@ -1,158 +0,0 @@
import React from 'react';
import Sockette from 'sockette';
import throttle from 'lodash/throttle';
import { withSnackbar, WithSnackbarProps } from 'notistack';
import { addAccessTokenParameter } from '../authentication';
import { extractEventValue } from '.';
export interface WebSocketControllerProps<D> extends WithSnackbarProps {
handleValueChange: (
name: keyof D
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
setData: (data: D, callback?: () => void) => void;
saveData: () => void;
saveDataAndClear(): () => void;
connected: boolean;
data?: D;
}
interface WebSocketControllerState<D> {
ws: Sockette;
connected: boolean;
clientId?: string;
data?: D;
}
enum WebSocketMessageType {
ID = 'id',
PAYLOAD = 'payload'
}
interface WebSocketIdMessage {
type: typeof WebSocketMessageType.ID;
id: string;
}
interface WebSocketPayloadMessage<D> {
type: typeof WebSocketMessageType.PAYLOAD;
origin_id: string;
payload: D;
}
export type WebSocketMessage<D> =
| WebSocketIdMessage
| WebSocketPayloadMessage<D>;
export function webSocketController<D, P extends WebSocketControllerProps<D>>(
wsUrl: string,
wsThrottle: number,
WebSocketController: React.ComponentType<P & WebSocketControllerProps<D>>
) {
return withSnackbar(
class extends React.Component<
Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps,
WebSocketControllerState<D>
> {
constructor(
props: Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps
) {
super(props);
this.state = {
ws: new Sockette(addAccessTokenParameter(wsUrl), {
onmessage: this.onMessage,
onopen: this.onOpen,
onclose: this.onClose
}),
connected: false
};
}
componentWillUnmount() {
this.state.ws.close();
}
onMessage = (event: MessageEvent) => {
const rawData = event.data;
if (typeof rawData === 'string' || rawData instanceof String) {
this.handleMessage(
JSON.parse(rawData as string) as WebSocketMessage<D>
);
}
};
handleMessage = (message: WebSocketMessage<D>) => {
const { clientId, data } = this.state;
switch (message.type) {
case WebSocketMessageType.ID:
this.setState({ clientId: message.id });
break;
case WebSocketMessageType.PAYLOAD:
if (clientId && (!data || clientId !== message.origin_id)) {
this.setState({ data: message.payload });
}
break;
}
};
onOpen = () => {
this.setState({ connected: true });
};
onClose = () => {
this.setState({
connected: false,
clientId: undefined,
data: undefined
});
};
setData = (data: D, callback?: () => void) => {
this.setState({ data }, callback);
};
saveData = throttle(() => {
const { ws, connected, data } = this.state;
if (connected) {
ws.json(data);
}
}, wsThrottle);
saveDataAndClear = throttle(() => {
const { ws, connected, data } = this.state;
if (connected) {
this.setState(
{
data: undefined
},
() => ws.json(data)
);
}
}, wsThrottle);
handleValueChange = (name: keyof D) => (
event: React.ChangeEvent<HTMLInputElement>
) => {
const data = { ...this.state.data!, [name]: extractEventValue(event) };
this.setState({ data });
};
render() {
return (
<WebSocketController
{...(this.props as P)}
handleValueChange={this.handleValueChange}
setData={this.setData}
saveData={this.saveData}
saveDataAndClear={this.saveDataAndClear}
connected={this.state.connected}
data={this.state.data}
/>
);
}
}
);
}

View File

@@ -1,43 +0,0 @@
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import { LinearProgress, Typography } from '@material-ui/core';
import { WebSocketControllerProps } from '.';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
loadingSettings: {
margin: theme.spacing(0.5)
},
loadingSettingsDetails: {
margin: theme.spacing(4),
textAlign: 'center'
}
})
);
export type WebSocketFormProps<D> = Omit<
WebSocketControllerProps<D>,
'connected'
> & { data: D };
interface WebSocketFormLoaderProps<D> extends WebSocketControllerProps<D> {
render: (props: WebSocketFormProps<D>) => JSX.Element;
}
export default function WebSocketFormLoader<D>(
props: WebSocketFormLoaderProps<D>
) {
const { connected, render, data, ...rest } = props;
const classes = useStyles();
if (!connected || !data) {
return (
<div className={classes.loadingSettings}>
<LinearProgress className={classes.loadingSettingsDetails} />
<Typography variant="h6" className={classes.loadingSettingsDetails}>
Connecting to WebSocket...
</Typography>
</div>
);
}
return render({ ...rest, data });
}

View File

@@ -1,14 +0,0 @@
import { useLayoutEffect, useState } from 'react';
export function useWindowSize() {
const [size, setSize] = useState([0, 0]);
useLayoutEffect(() => {
function updateSize() {
setSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener('resize', updateSize);
updateSize();
return () => window.removeEventListener('resize', updateSize);
}, []);
return size;
}

View File

@@ -1,20 +1,8 @@
export { default as BlockFormControlLabel } from './BlockFormControlLabel';
export { default as FormActions } from './FormActions';
export { default as FormButton } from './FormButton';
export { default as HighlightAvatar } from './HighlightAvatar';
export { default as MenuAppBar } from './MenuAppBar';
export { default as PasswordValidator } from './PasswordValidator';
export { default as RestFormLoader } from './RestFormLoader';
export { default as FormLoader } from './FormLoader';
export * from './inputs';
export * from './layout';
export * from './loading';
export * from './routing';
export * from './upload';
export { default as SectionContent } from './SectionContent';
export { default as WebSocketFormLoader } from './WebSocketFormLoader';
export { default as ErrorButton } from './ErrorButton';
export { default as SingleUpload } from './SingleUpload';
export * from './RestFormLoader';
export * from './RestController';
export * from './WebSocketFormLoader';
export * from './WebSocketController';
export * from './WindowSize';
export { default as ButtonRow } from './ButtonRow';
export { default as MessageBox } from './MessageBox';

View File

@@ -1,5 +1,5 @@
import { FC } from 'react';
import { FormControlLabel, FormControlLabelProps } from '@material-ui/core';
import { FormControlLabel, FormControlLabelProps } from '@mui/material';
const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
<div>

View File

@@ -0,0 +1,36 @@
import { FC, useState } from 'react';
import { IconButton, InputAdornment } from '@mui/material';
import VisibilityIcon from '@mui/icons-material/Visibility';
import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
import ValidatedTextField, { ValidatedTextFieldProps } from './ValidatedTextField';
type ValidatedPasswordFieldProps = Omit<ValidatedTextFieldProps, 'type'>;
const ValidatedPasswordField: FC<ValidatedPasswordFieldProps> = ({ InputProps, ...props }) => {
const [showPassword, setShowPassword] = useState<boolean>(false);
return (
<ValidatedTextField
{...props}
type={showPassword ? 'text' : 'password'}
InputProps={{
...InputProps,
endAdornment: (
<InputAdornment position="end">
<IconButton
aria-label="toggle password visibility"
onClick={() => setShowPassword(!showPassword)}
edge="end"
>
{showPassword ? <VisibilityIcon /> : <VisibilityOffIcon />}
</IconButton>
</InputAdornment>
)
}}
/>
);
};
export default ValidatedPasswordField;

View File

@@ -0,0 +1,24 @@
import { FC } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { FormHelperText, TextField, TextFieldProps } from '@mui/material';
interface ValidatedFieldProps {
fieldErrors?: ValidateFieldsError;
name: string;
}
export type ValidatedTextFieldProps = ValidatedFieldProps & TextFieldProps;
const ValidatedTextField: FC<ValidatedTextFieldProps> = ({ fieldErrors, ...rest }) => {
const errors = fieldErrors && fieldErrors[rest.name];
const renderErrors = () => errors && errors.map((e, i) => <FormHelperText key={i}>{e.message}</FormHelperText>);
return (
<>
<TextField error={!!errors} {...rest} />
{renderErrors()}
</>
);
};
export default ValidatedTextField;

View File

@@ -0,0 +1,3 @@
export { default as BlockFormControlLabel } from './BlockFormControlLabel';
export { default as ValidatedPasswordField } from './ValidatedPasswordField';
export { default as ValidatedTextField } from './ValidatedTextField';

View File

@@ -0,0 +1,36 @@
import { FC, useState, useEffect } from 'react';
import { useLocation } from 'react-router-dom';
import { Box, Toolbar } from '@mui/material';
import { PROJECT_NAME } from '../../api/env';
import LayoutDrawer from './LayoutDrawer';
import LayoutAppBar from './LayoutAppBar';
import { LayoutContext } from './context';
export const DRAWER_WIDTH = 240;
const Layout: FC = ({ children }) => {
const [mobileOpen, setMobileOpen] = useState(false);
const [title, setTitle] = useState(PROJECT_NAME);
const { pathname } = useLocation();
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
useEffect(() => setMobileOpen(false), [pathname]);
return (
<LayoutContext.Provider value={{ title, setTitle }}>
<LayoutAppBar title={title} onToggleDrawer={handleDrawerToggle} />
<LayoutDrawer mobileOpen={mobileOpen} onClose={handleDrawerToggle} />
<Box component="main" sx={{ marginLeft: { md: `${DRAWER_WIDTH}px` } }}>
<Toolbar />
{children}
</Box>
</LayoutContext.Provider>
);
};
export default Layout;

View File

@@ -0,0 +1,51 @@
import { FC, useContext } from 'react';
import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material';
import MenuIcon from '@mui/icons-material/Menu';
import LayoutAuthMenu from './LayoutAuthMenu';
import { FeaturesContext } from '../../contexts/features';
export const DRAWER_WIDTH = 240;
interface LayoutAppBarProps {
title: string;
onToggleDrawer: () => void;
}
const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => {
const { features } = useContext(FeaturesContext);
return (
<AppBar
position="fixed"
sx={{
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
ml: { md: `${DRAWER_WIDTH}px` },
boxShadow: 'none',
backgroundColor: '#2e586a'
// color: "#2196f3",
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={onToggleDrawer}
sx={{ mr: 2, display: { md: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div">
{title}
</Typography>
<Box flexGrow={1} />
{features.security && <LayoutAuthMenu />}
</Toolbar>
</AppBar>
);
};
export default LayoutAppBar;

View File

@@ -0,0 +1,73 @@
import { FC, useState, useContext } from 'react';
import { Box, Button, Divider, IconButton, Popover, Typography, Avatar, styled, TypographyProps } from '@mui/material';
import PersonIcon from '@mui/icons-material/Person';
import AccountCircleIcon from '@mui/icons-material/AccountCircle';
import { AuthenticatedContext } from '../../contexts/authentication';
const ItemTypography = styled(Typography)<TypographyProps>({
maxWidth: '250px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis'
});
const LayoutAuthMenu: FC = () => {
const { me, signOut } = useContext(AuthenticatedContext);
const [anchorEl, setAnchorEl] = useState<HTMLButtonElement | null>(null);
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
const open = Boolean(anchorEl);
const id = anchorEl ? 'app-menu-popover' : undefined;
return (
<>
<IconButton id="open-auth-menu" sx={{ padding: 0 }} aria-describedby={id} color="inherit" onClick={handleClick}>
<AccountCircleIcon />
</IconButton>
<Popover
id="app-menu-popover"
sx={{ mt: 1 }}
open={open}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'center'
}}
transformOrigin={{
vertical: 'top',
horizontal: 'center'
}}
>
<Box display="flex" flexDirection="row" alignItems="center" p={2}>
<Avatar sx={{ width: 80, height: 80 }}>
<PersonIcon fontSize="large" />
</Avatar>
<Box pl={2}>
<ItemTypography variant="h6">{me.username}</ItemTypography>
<ItemTypography variant="body1">{me.admin ? 'Admin User' : 'Guest User'}</ItemTypography>
</Box>
</Box>
<Divider />
<Box p={1.5}>
<Button variant="outlined" fullWidth color="primary" onClick={() => signOut(true)}>
Sign Out
</Button>
</Box>
</Popover>
</>
);
};
export default LayoutAuthMenu;

View File

@@ -0,0 +1,73 @@
import { FC } from 'react';
import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
import { PROJECT_NAME } from '../../api/env';
import LayoutMenu from './LayoutMenu';
import { DRAWER_WIDTH } from './Layout';
const LayoutDrawerLogo = styled('img')(({ theme }) => ({
[theme.breakpoints.down('sm')]: {
height: 24,
marginRight: theme.spacing(2)
},
[theme.breakpoints.up('sm')]: {
height: 36,
marginRight: theme.spacing(2)
}
}));
interface LayoutDrawerProps {
mobileOpen: boolean;
onClose: () => void;
}
const LayoutDrawer: FC<LayoutDrawerProps> = ({ mobileOpen, onClose }) => {
const drawer = (
<>
<Toolbar disableGutters>
<Box display="flex" alignItems="center" px={2}>
<LayoutDrawerLogo src="/app/icon.png" alt={PROJECT_NAME} />
<Typography variant="h6" color="textPrimary">
{PROJECT_NAME}
</Typography>
</Box>
<Divider absolute />
</Toolbar>
<Divider />
<LayoutMenu />
</>
);
return (
<Box component="nav" sx={{ width: { md: DRAWER_WIDTH }, flexShrink: { md: 0 } }}>
<Drawer
variant="temporary"
open={mobileOpen}
onClose={onClose}
ModalProps={{
keepMounted: true // Better open performance on mobile.
}}
sx={{
display: { xs: 'block', md: 'none' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: DRAWER_WIDTH }
}}
>
{drawer}
</Drawer>
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', md: 'block' },
'& .MuiDrawer-paper': { boxSizing: 'border-box', width: DRAWER_WIDTH }
}}
open
>
{drawer}
</Drawer>
</Box>
);
};
export default LayoutDrawer;

View File

@@ -0,0 +1,42 @@
import { FC, useContext } from 'react';
import { Divider, List } from '@mui/material';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import SettingsIcon from '@mui/icons-material/Settings';
import LockIcon from '@mui/icons-material/Lock';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import { FeaturesContext } from '../../contexts/features';
import ProjectMenu from '../../project/ProjectMenu';
import LayoutMenuItem from './LayoutMenuItem';
import { AuthenticatedContext } from '../../contexts/authentication';
const LayoutMenu: FC = () => {
const { features } = useContext(FeaturesContext);
const authenticatedContext = useContext(AuthenticatedContext);
return (
<>
{features.project && (
<List disablePadding component="nav">
<ProjectMenu />
<Divider />
</List>
)}
<List disablePadding component="nav">
<LayoutMenuItem icon={SettingsEthernetIcon} label="Network Connection" to="/network" />
<LayoutMenuItem icon={SettingsInputAntennaIcon} label="Access Point" to="/ap" />
{features.ntp && <LayoutMenuItem icon={AccessTimeIcon} label="Network Time" to="/ntp" />}
{features.mqtt && <LayoutMenuItem icon={DeviceHubIcon} label="MQTT" to="/mqtt" />}
<LayoutMenuItem icon={LockIcon} label="Security" to="/security" disabled={!authenticatedContext.me.admin} />
<LayoutMenuItem icon={SettingsIcon} label="System" to="/system" />
</List>
</>
);
};
export default LayoutMenu;

View File

@@ -0,0 +1,32 @@
import { FC } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { ListItem, ListItemButton, ListItemIcon, ListItemText, SvgIconProps } from '@mui/material';
import { grey } from '@mui/material/colors';
import { routeMatches } from '../../utils';
interface LayoutMenuItemProps {
icon: React.ComponentType<SvgIconProps>;
label: string;
to: string;
disabled?: boolean;
}
const LayoutMenuItem: FC<LayoutMenuItemProps> = ({ icon: Icon, label, to, disabled }) => {
const { pathname } = useLocation();
return (
<ListItem disablePadding selected={routeMatches(to, pathname)}>
<ListItemButton component={Link} to={to} disabled={disabled}>
<ListItemIcon sx={{ color: grey[500] }}>
<Icon />
</ListItemIcon>
<ListItemText>{label}</ListItemText>
</ListItemButton>
</ListItem>
);
};
export default LayoutMenuItem;

View File

@@ -0,0 +1,25 @@
import { useRef, useEffect, createContext, useContext } from 'react';
export interface LayoutContextValue {
title: string;
setTitle: (title: string) => void;
}
const LayoutContextDefaultValue = {} as LayoutContextValue;
export const LayoutContext = createContext(LayoutContextDefaultValue);
export const useLayoutTitle = (myTitle: string) => {
const { title, setTitle } = useContext(LayoutContext);
const previousTitle = useRef(title);
useEffect(() => {
setTitle(myTitle);
}, [setTitle, myTitle]);
useEffect(
() => () => {
setTitle(previousTitle.current);
},
[setTitle]
);
};

View File

@@ -0,0 +1,2 @@
export * from './context';
export { default as Layout } from './Layout';

View File

@@ -0,0 +1,43 @@
import { FC } from 'react';
import { Box, Paper, Typography } from '@mui/material';
import WarningIcon from '@mui/icons-material/Warning';
interface ApplicationErrorProps {
message?: string;
}
const ApplicationError: FC<ApplicationErrorProps> = ({ message }) => (
<Box display="flex" height="100vh" justifyContent="center" flexDirection="column">
<Paper
elevation={10}
sx={{
textAlign: 'center',
padding: '280px 0 40px 0',
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: 'no-repeat',
backgroundPosition: '50% 40px',
backgroundSize: '200px auto',
width: '100%',
borderRadius: 0
}}
>
<Box display="flex" flexDirection="row" justifyContent="center" alignItems="center" mb={2}>
<WarningIcon fontSize="large" color="error" />
<Box ml={2}>
<Typography variant="h4">Application Error</Typography>
</Box>
</Box>
<Typography variant="subtitle1" gutterBottom>
Failed to configure the application, please refresh to try again.
</Typography>
{message && (
<Typography variant="subtitle2" gutterBottom>
{message}
</Typography>
)}
</Paper>
</Box>
);
export default ApplicationError;

View File

@@ -0,0 +1,38 @@
import { FC } from 'react';
import { Box, Button, CircularProgress, Typography } from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import { MessageBox } from '..';
interface FormLoaderProps {
message?: string;
errorMessage?: string;
onRetry?: () => void;
}
const FormLoader: FC<FormLoaderProps> = ({ errorMessage, onRetry, message = 'Loading…' }) => {
if (errorMessage) {
return (
<MessageBox my={2} level="error" message={errorMessage}>
{onRetry && (
<Button startIcon={<RefreshIcon />} variant="contained" color="error" onClick={onRetry}>
Retry
</Button>
)}
</MessageBox>
);
}
return (
<Box m={2} py={2} display="flex" alignItems="center" flexDirection="column">
<Box py={2}>
<CircularProgress size={100} />
</Box>
<Typography variant="h6" fontWeight={400} textAlign="center">
{message}
</Typography>
</Box>
);
};
export default FormLoader;

View File

@@ -0,0 +1,24 @@
import { FC } from 'react';
import { CircularProgress, Box, Typography, Theme } from '@mui/material';
interface LoadingSpinnerProps {
height?: number | string;
}
const LoadingSpinner: FC<LoadingSpinnerProps> = ({ height = '100%' }) => (
<Box display="flex" alignItems="center" justifyContent="center" flexDirection="column" padding={2} height={height}>
<CircularProgress
sx={(theme: Theme) => ({
margin: theme.spacing(4),
color: theme.palette.text.secondary
})}
size={100}
/>
<Typography variant="h4" color="textSecondary">
Loading&hellip;
</Typography>
</Box>
);
export default LoadingSpinner;

View File

@@ -0,0 +1,3 @@
export { default as ApplicationError } from './ApplicationError';
export { default as LoadingSpinner } from './LoadingSpinner';
export { default as FormLoader } from './FormLoader';

View File

@@ -0,0 +1,11 @@
import { FC, useContext } from 'react';
import { Navigate } from 'react-router-dom';
import { AuthenticatedContext } from '../../contexts/authentication';
const RequireAdmin: FC = ({ children }) => {
const authenticatedContext = useContext(AuthenticatedContext);
return authenticatedContext.me.admin ? <>{children}</> : <Navigate replace to="/" />;
};
export default RequireAdmin;

View File

@@ -0,0 +1,30 @@
import { FC, useContext, useEffect } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import {
AuthenticatedContext,
AuthenticatedContextValue,
AuthenticationContext
} from '../../contexts/authentication/context';
import { storeLoginRedirect } from '../../api/authentication';
const RequireAuthenticated: FC = ({ children }) => {
const authenticationContext = useContext(AuthenticationContext);
const location = useLocation();
useEffect(() => {
if (!authenticationContext.me) {
storeLoginRedirect(location);
}
});
return authenticationContext.me ? (
<AuthenticatedContext.Provider value={authenticationContext as AuthenticatedContextValue}>
{children}
</AuthenticatedContext.Provider>
) : (
<Navigate to="/unauthorized" />
);
};
export default RequireAuthenticated;

View File

@@ -0,0 +1,15 @@
import { FC, useContext } from 'react';
import { Navigate } from 'react-router-dom';
import * as AuthenticationApi from '../../api/authentication';
import { AuthenticationContext } from '../../contexts/authentication';
import { FeaturesContext } from '../../contexts/features';
const RequireUnauthenticated: FC = ({ children }) => {
const { features } = useContext(FeaturesContext);
const authenticationContext = useContext(AuthenticationContext);
return authenticationContext.me ? <Navigate to={AuthenticationApi.fetchLoginRedirect(features)} /> : <>{children}</>;
};
export default RequireUnauthenticated;

View File

@@ -0,0 +1,27 @@
import React, { FC } from 'react';
import { useNavigate } from 'react-router-dom';
import { Tabs, useMediaQuery, useTheme } from '@mui/material';
interface RouterTabsProps {
value: string | false;
}
const RouterTabs: FC<RouterTabsProps> = ({ value, children }) => {
const navigate = useNavigate();
const theme = useTheme();
const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
const handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
navigate(path);
};
return (
<Tabs value={value} onChange={handleTabChange} variant={smallDown ? 'scrollable' : 'fullWidth'}>
{children}
</Tabs>
);
};
export default RouterTabs;

View File

@@ -0,0 +1,6 @@
export { default as RouterTabs } from './RouterTabs';
export { default as RequireAdmin } from './RequireAdmin';
export { default as RequireAuthenticated } from './RequireAuthenticated';
export { default as RequireUnauthenticated } from './RequireUnauthenticated';
export * from './useRouterTab';

View File

@@ -0,0 +1,9 @@
import { useMatch, useResolvedPath } from 'react-router-dom';
export const useRouterTab = () => {
const routerTabPath = useResolvedPath(':tab');
const routerTabPathMatch = useMatch(routerTabPath.pathname);
const routerTab = routerTabPathMatch?.params?.tab || false;
return { routerTab } as const;
};

View File

@@ -0,0 +1,86 @@
import { FC, Fragment } from 'react';
import { useDropzone, DropzoneState } from 'react-dropzone';
import { Box, Button, LinearProgress, Theme, Typography, useTheme } from '@mui/material';
import CloudUploadIcon from '@mui/icons-material/CloudUpload';
import CancelIcon from '@mui/icons-material/Cancel';
const progressPercentage = (progress: ProgressEvent) => Math.round((progress.loaded * 100) / progress.total);
const getBorderColor = (theme: Theme, props: DropzoneState) => {
if (props.isDragAccept) {
return theme.palette.success.main;
}
if (props.isDragReject) {
return theme.palette.error.main;
}
if (props.isDragActive) {
return theme.palette.info.main;
}
return theme.palette.grey[700];
};
export interface SingleUploadProps {
onDrop: (acceptedFiles: File[]) => void;
onCancel: () => void;
accept?: string | string[];
uploading: boolean;
progress?: ProgressEvent;
}
const SingleUpload: FC<SingleUploadProps> = ({ onDrop, onCancel, accept, uploading, progress }) => {
const dropzoneState = useDropzone({ onDrop, accept, disabled: uploading, multiple: false });
const { getRootProps, getInputProps } = dropzoneState;
const theme = useTheme();
const progressText = () => {
if (uploading) {
if (progress?.lengthComputable) {
return `Uploading: ${progressPercentage(progress)}%`;
}
return 'Uploading\u2026';
}
return 'Drop file or click here';
};
return (
<Box
{...getRootProps({
sx: {
py: 8,
px: 2,
borderWidth: 2,
borderRadius: 2,
borderStyle: 'dashed',
color: theme.palette.grey[700],
transition: 'border .24s ease-in-out',
width: '100%',
cursor: uploading ? 'default' : 'pointer',
borderColor: getBorderColor(theme, dropzoneState)
}
})}
>
<input {...getInputProps()} />
<Box flexDirection="column" display="flex" alignItems="center">
<CloudUploadIcon fontSize="large" />
<Typography variant="h6">{progressText()}</Typography>
{uploading && (
<Fragment>
<Box width="100%" p={2}>
<LinearProgress
variant={!progress || progress.lengthComputable ? 'determinate' : 'indeterminate'}
value={!progress ? 0 : progress.lengthComputable ? progressPercentage(progress) : 0}
/>
</Box>
<Button startIcon={<CancelIcon />} variant="outlined" color="secondary" onClick={onCancel}>
Cancel
</Button>
</Fragment>
)}
</Box>
</Box>
);
};
export default SingleUpload;

View File

@@ -0,0 +1,2 @@
export { default as SingleUpload } from './SingleUpload';
export { default as useFileUpload } from './useFileUpload';

View File

@@ -0,0 +1,59 @@
import { useCallback, useEffect, useState } from 'react';
import axios, { AxiosPromise, CancelTokenSource } from 'axios';
import { useSnackbar } from 'notistack';
import { extractErrorMessage } from '../../utils';
import { FileUploadConfig } from '../../api/endpoints';
interface MediaUploadOptions {
upload: (file: File, config?: FileUploadConfig) => AxiosPromise<void>;
}
const useFileUpload = ({ upload }: MediaUploadOptions) => {
const { enqueueSnackbar } = useSnackbar();
const [uploading, setUploading] = useState<boolean>(false);
const [uploadProgress, setUploadProgress] = useState<ProgressEvent>();
const [uploadCancelToken, setUploadCancelToken] = useState<CancelTokenSource>();
const resetUploadingStates = () => {
setUploading(false);
setUploadProgress(undefined);
setUploadCancelToken(undefined);
};
const cancelUpload = useCallback(() => {
uploadCancelToken?.cancel();
resetUploadingStates();
}, [uploadCancelToken]);
useEffect(() => {
return () => {
uploadCancelToken?.cancel();
};
}, [uploadCancelToken]);
const uploadFile = async (images: File[]) => {
try {
const cancelToken = axios.CancelToken.source();
setUploadCancelToken(cancelToken);
setUploading(true);
await upload(images[0], {
onUploadProgress: setUploadProgress,
cancelToken: cancelToken.token
});
resetUploadingStates();
enqueueSnackbar('Upload successful', { variant: 'success' });
} catch (error: any) {
if (axios.isCancel(error)) {
enqueueSnackbar('Upload aborted', { variant: 'warning' });
} else {
resetUploadingStates();
enqueueSnackbar(extractErrorMessage(error, 'Upload failed'), { variant: 'error' });
}
}
};
return [uploadFile, cancelUpload, uploading, uploadProgress] as const;
};
export default useFileUpload;

View File

@@ -0,0 +1,84 @@
import { FC, useCallback, useContext, useEffect, useState } from 'react';
import { useSnackbar } from 'notistack';
import { useNavigate } from 'react-router-dom';
import * as AuthenticationApi from '../../api/authentication';
import { ACCESS_TOKEN } from '../../api/endpoints';
import { LoadingSpinner } from '../../components';
import { Me } from '../../types';
import { FeaturesContext } from '../features';
import { AuthenticationContext } from './context';
const Authentication: FC = ({ children }) => {
const { features } = useContext(FeaturesContext);
const navigate = useNavigate();
const { enqueueSnackbar } = useSnackbar();
const [initialized, setInitialized] = useState<boolean>(false);
const [me, setMe] = useState<Me>();
const signIn = (accessToken: string) => {
try {
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
setMe(decodedMe);
enqueueSnackbar(`Logged in as ${decodedMe.username}`, { variant: 'success' });
} catch (error: any) {
setMe(undefined);
throw new Error('Failed to parse JWT ' + error.message);
}
};
const signOut = (redirect: boolean) => {
AuthenticationApi.clearAccessToken();
setMe(undefined);
if (redirect) {
navigate('/');
}
};
const refresh = useCallback(async () => {
if (!features.security) {
setMe({ admin: true, username: 'admin' });
setInitialized(true);
return;
}
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
if (accessToken) {
try {
await AuthenticationApi.verifyAuthorization();
setMe(AuthenticationApi.decodeMeJWT(accessToken));
setInitialized(true);
} catch (error: any) {
setMe(undefined);
setInitialized(true);
}
} else {
setMe(undefined);
setInitialized(true);
}
}, [features]);
useEffect(() => {
refresh();
}, [refresh]);
if (initialized) {
return (
<AuthenticationContext.Provider
value={{
signIn,
signOut,
me,
refresh
}}
>
{children}
</AuthenticationContext.Provider>
);
}
return <LoadingSpinner height="100vh" />;
};
export default Authentication;

View File

@@ -0,0 +1,19 @@
import { createContext } from 'react';
import { Me } from '../../types';
export interface AuthenticationContextValue {
refresh: () => Promise<void>;
signIn: (accessToken: string) => void;
signOut: (redirect: boolean) => void;
me?: Me;
}
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
export const AuthenticationContext = createContext(AuthenticationContextDefaultValue);
export interface AuthenticatedContextValue extends AuthenticationContextValue {
me: Me;
}
const AuthenticatedContextDefaultValue = {} as AuthenticatedContextValue;
export const AuthenticatedContext = createContext(AuthenticatedContextDefaultValue);

View File

@@ -0,0 +1,2 @@
export * from './context';
export { default as Authentication } from './Authentication';

View File

@@ -0,0 +1,47 @@
import { FC, useCallback, useEffect, useState } from 'react';
import * as FeaturesApi from '../../api/features';
import { extractErrorMessage } from '../../utils';
import { Features } from '../../types';
import { ApplicationError, LoadingSpinner } from '../../components';
import { FeaturesContext } from '.';
const FeaturesLoader: FC = (props) => {
const [errorMessage, setErrorMessage] = useState<string>();
const [features, setFeatures] = useState<Features>();
const loadFeatures = useCallback(async () => {
try {
const response = await FeaturesApi.readFeatures();
setFeatures(response.data);
} catch (error: any) {
setErrorMessage(extractErrorMessage(error, 'Failed to fetch application details.'));
}
}, []);
useEffect(() => {
loadFeatures();
}, [loadFeatures]);
if (features) {
return (
<FeaturesContext.Provider
value={{
features
}}
>
{props.children}
</FeaturesContext.Provider>
);
}
if (errorMessage) {
return <ApplicationError message={errorMessage} />;
}
return <LoadingSpinner height="100vh" />;
};
export default FeaturesLoader;

View File

@@ -0,0 +1,10 @@
import { createContext } from 'react';
import { Features } from '../../types';
export interface FeaturesContextValue {
features: Features;
}
const FeaturesContextDefaultValue = {} as FeaturesContextValue;
export const FeaturesContext = createContext(FeaturesContextDefaultValue);

View File

@@ -0,0 +1,2 @@
export * from './context';
export { default as FeaturesLoader } from './FeaturesLoader';

View File

@@ -1,32 +0,0 @@
import React from 'react';
import { Features } from './types';
export interface FeaturesContextValue {
features: Features;
}
const FeaturesContextDefaultValue = {} as FeaturesContextValue;
export const FeaturesContext = React.createContext(FeaturesContextDefaultValue);
export interface WithFeaturesProps {
features: Features;
}
export function withFeatures<T extends WithFeaturesProps>(
Component: React.ComponentType<T>
) {
return class extends React.Component<Omit<T, keyof WithFeaturesProps>> {
render() {
return (
<FeaturesContext.Consumer>
{(featuresContext) => (
<Component
{...(this.props as T)}
features={featuresContext.features}
/>
)}
</FeaturesContext.Consumer>
);
}
};
}

View File

@@ -1,31 +0,0 @@
import { FC } from 'react';
import FullScreenLoading from '../components/FullScreenLoading';
import ApplicationError from '../components/ApplicationError';
import { FEATURES_ENDPOINT } from '../api';
import { useRest } from '../hooks';
import { Features } from './types';
import { FeaturesContext } from './FeaturesContext';
const FeaturesWrapper: FC = ({ children }) => {
const { data: features, errorMessage: error } = useRest<Features>({
endpoint: FEATURES_ENDPOINT
});
if (features) {
return (
<FeaturesContext.Provider value={{ features }}>
{children}
</FeaturesContext.Provider>
);
}
if (error) {
return <ApplicationError error={error} />;
}
return <FullScreenLoading />;
};
export default FeaturesWrapper;

View File

@@ -0,0 +1,184 @@
import { FC, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { range } from 'lodash';
import { Button, Checkbox, MenuItem } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import { createAPSettingsValidator, validate } from '../../validators';
import {
BlockFormControlLabel,
ButtonRow,
FormLoader,
SectionContent,
ValidatedPasswordField,
ValidatedTextField
} from '../../components';
import { APProvisionMode, APSettings } from '../../types';
import { numberValue, updateValue, useRest } from '../../utils';
import * as APApi from '../../api/ap';
export const isAPEnabled = ({ provision_mode }: APSettings) => {
return provision_mode === APProvisionMode.AP_MODE_ALWAYS || provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
};
const APSettingsForm: FC = () => {
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<APSettings>({
read: APApi.readAPSettings,
update: APApi.updateAPSettings
});
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setData);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createAPSettingsValidator(data), data);
saveData();
} catch (errors: any) {
setFieldErrors(errors);
}
};
return (
<>
<ValidatedTextField
fieldErrors={fieldErrors}
name="provision_mode"
label="Provide Access Point&hellip;"
value={data.provision_mode}
fullWidth
select
variant="outlined"
onChange={updateFormValue}
margin="normal"
>
<MenuItem value={APProvisionMode.AP_MODE_ALWAYS}>Always</MenuItem>
<MenuItem value={APProvisionMode.AP_MODE_DISCONNECTED}>When WiFi Disconnected</MenuItem>
<MenuItem value={APProvisionMode.AP_NEVER}>Never</MenuItem>
</ValidatedTextField>
{isAPEnabled(data) && (
<>
<ValidatedTextField
fieldErrors={fieldErrors}
name="ssid"
label="Access Point SSID"
fullWidth
variant="outlined"
value={data.ssid}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedPasswordField
fieldErrors={fieldErrors}
name="password"
label="Access Point Password"
fullWidth
variant="outlined"
value={data.password}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="channel"
label="Preferred Channel"
value={numberValue(data.channel)}
fullWidth
select
type="number"
variant="outlined"
onChange={updateFormValue}
margin="normal"
>
{range(1, 14).map((i) => (
<MenuItem key={i} value={i}>
{i}
</MenuItem>
))}
</ValidatedTextField>
<BlockFormControlLabel
control={<Checkbox name="ssid_hidden" checked={data.ssid_hidden} onChange={updateFormValue} />}
label="Hide SSID"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="max_clients"
label="Max Clients"
value={numberValue(data.max_clients)}
fullWidth
select
type="number"
variant="outlined"
onChange={updateFormValue}
margin="normal"
>
{range(1, 9).map((i) => (
<MenuItem key={i} value={i}>
{i}
</MenuItem>
))}
</ValidatedTextField>
<ValidatedTextField
fieldErrors={fieldErrors}
name="local_ip"
label="Local IP"
fullWidth
variant="outlined"
value={data.local_ip}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="gateway_ip"
label="Gateway"
fullWidth
variant="outlined"
value={data.gateway_ip}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="subnet_mask"
label="Subnet"
fullWidth
variant="outlined"
value={data.subnet_mask}
onChange={updateFormValue}
margin="normal"
/>
</>
)}
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
Save
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="Access Point Settings" titleGutter>
{content()}
</SectionContent>
);
};
export default APSettingsForm;

View File

@@ -0,0 +1,104 @@
import { FC } from 'react';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, Theme, useTheme } from '@mui/material';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ComputerIcon from '@mui/icons-material/Computer';
import RefreshIcon from '@mui/icons-material/Refresh';
import * as APApi from '../../api/ap';
import { APNetworkStatus, APStatus } from '../../types';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { useRest } from '../../utils';
export const apStatusHighlight = ({ status }: APStatus, theme: Theme) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return theme.palette.success.main;
case APNetworkStatus.INACTIVE:
return theme.palette.info.main;
case APNetworkStatus.LINGERING:
return theme.palette.warning.main;
default:
return theme.palette.warning.main;
}
};
export const apStatus = ({ status }: APStatus) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return 'Active';
case APNetworkStatus.INACTIVE:
return 'Inactive';
case APNetworkStatus.LINGERING:
return 'Lingering until idle';
default:
return 'Unknown';
}
};
const APStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<APStatus>({ read: APApi.readAPStatus });
const theme = useTheme();
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: apStatusHighlight(data, theme) }}>
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={apStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>IP</Avatar>
</ListItemAvatar>
<ListItemText primary="IP Address" secondary={data.ip_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="MAC Address" secondary={data.mac_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<ComputerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="AP Clients" secondary={data.station_num} />
</ListItem>
<Divider variant="inset" component="li" />
</List>
<ButtonRow>
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={loadData}>
Refresh
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="Access Point Status" titleGutter>
{content()}
</SectionContent>
);
};
export default APStatusForm;

View File

@@ -0,0 +1,40 @@
import { FC, useContext } from 'react';
import { Navigate, Routes, Route } from 'react-router-dom';
import { Tab } from '@mui/material';
import { AuthenticatedContext } from '../../contexts/authentication';
import APStatusForm from './APStatusForm';
import APSettingsForm from './APSettingsForm';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
const AccessPoint: FC = () => {
useLayoutTitle('Access Point');
const authenticatedContext = useContext(AuthenticatedContext);
const { routerTab } = useRouterTab();
return (
<>
<RouterTabs value={routerTab}>
<Tab value="status" label="Access Point Status" />
<Tab value="settings" label="Access Point Settings" disabled={!authenticatedContext.me.admin} />
</RouterTabs>
<Routes>
<Route path="status" element={<APStatusForm />} />
<Route
path="settings"
element={
<RequireAdmin>
<APSettingsForm />
</RequireAdmin>
}
/>
<Route path="/*" element={<Navigate replace to="status" />} />
</Routes>
</>
);
};
export default AccessPoint;

View File

@@ -0,0 +1,40 @@
import React, { FC, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Tab } from '@mui/material';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
import { AuthenticatedContext } from '../../contexts/authentication';
import MqttStatusForm from './MqttStatusForm';
import MqttSettingsForm from './MqttSettingsForm';
const Mqtt: FC = () => {
useLayoutTitle('MQTT');
const authenticatedContext = useContext(AuthenticatedContext);
const { routerTab } = useRouterTab();
return (
<>
<RouterTabs value={routerTab}>
<Tab value="status" label="MQTT Status" />
<Tab value="settings" label="MQTT Settings" disabled={!authenticatedContext.me.admin} />
</RouterTabs>
<Routes>
<Route path="status" element={<MqttStatusForm />} />
<Route
path="settings"
element={
<RequireAdmin>
<MqttSettingsForm />
</RequireAdmin>
}
/>
<Route path="/*" element={<Navigate replace to="status" />} />
</Routes>
</>
);
};
export default Mqtt;

View File

@@ -0,0 +1,315 @@
import { FC, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { Button, Checkbox, MenuItem, Grid, Typography } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import { MQTT_SETTINGS_VALIDATOR, validate } from '../../validators';
import {
BlockFormControlLabel,
ButtonRow,
FormLoader,
SectionContent,
ValidatedPasswordField,
ValidatedTextField
} from '../../components';
import { MqttSettings } from '../../types';
import { numberValue, updateValue, useRest } from '../../utils';
import * as MqttApi from '../../api/mqtt';
const MqttSettingsForm: FC = () => {
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<MqttSettings>({
read: MqttApi.readMqttSettings,
update: MqttApi.updateMqttSettings
});
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateFormValue = updateValue(setData);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(MQTT_SETTINGS_VALIDATOR, data);
saveData();
} catch (errors: any) {
setFieldErrors(errors);
}
};
return (
<>
<BlockFormControlLabel
control={<Checkbox name="enabled" checked={data.enabled} onChange={updateFormValue} />}
label="Enable MQTT"
/>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="host"
label="Host"
fullWidth
variant="outlined"
value={data.host}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="port"
label="Port"
fullWidth
variant="outlined"
value={numberValue(data.port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="base"
label="Bsse"
fullWidth
variant="outlined"
value={data.base}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={6}>
<ValidatedTextField
name="client_id"
label="Client ID (optional)"
fullWidth
variant="outlined"
value={data.client_id}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={6}>
<ValidatedTextField
name="username"
label="Username"
fullWidth
variant="outlined"
value={data.username}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={6}>
<ValidatedPasswordField
name="password"
label="Password"
fullWidth
variant="outlined"
value={data.password}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="keep_alive"
label="Keep Alive (seconds)"
fullWidth
variant="outlined"
value={numberValue(data.keep_alive)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={6}>
<ValidatedTextField
name="mqtt_qos"
label="QoS"
value={data.mqtt_qos}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>0 (default)</MenuItem>
<MenuItem value={1}>1</MenuItem>
<MenuItem value={2}>2</MenuItem>
</ValidatedTextField>
</Grid>
</Grid>
<BlockFormControlLabel
control={<Checkbox name="clean_session" checked={data.clean_session} onChange={updateFormValue} />}
label="Set Clean Session"
/>
<BlockFormControlLabel
control={<Checkbox name="mqtt_retain" checked={data.mqtt_retain} onChange={updateFormValue} />}
label="Always use Retain Flag"
/>
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
Formatting
</Typography>
<ValidatedTextField
name="nested_format"
label="Topic/Payload Format"
value={data.nested_format}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={1}>Nested in a single topic</MenuItem>
<MenuItem value={2}>As individual topics</MenuItem>
</ValidatedTextField>
<BlockFormControlLabel
control={<Checkbox name="send_response" checked={data.send_response} onChange={updateFormValue} />}
label="Publish command output to a 'response' topic"
/>
<BlockFormControlLabel
control={<Checkbox name="publish_single" checked={data.publish_single} onChange={updateFormValue} />}
label="Publish single value topics on change"
/>
<Grid item>
<BlockFormControlLabel
control={<Checkbox name="ha_enabled" checked={data.ha_enabled} onChange={updateFormValue} />}
label="Enable MQTT Discovery (for Home Assistant, Domoticz)"
/>
</Grid>
{data.ha_enabled && (
<Grid item xs={6}>
<ValidatedTextField
name="discovery_prefix"
label="Prefix for the Discovery topic"
fullWidth
variant="outlined"
value={data.discovery_prefix}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
)}
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
Publish Intervals (in seconds, 0=automatic)
</Typography>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_boiler"
label="Boilers and Heat Pumps"
fullWidth
variant="outlined"
value={numberValue(data.publish_time_boiler)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_thermostat"
label="Thermostats"
fullWidth
variant="outlined"
value={numberValue(data.publish_time_thermostat)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_solar"
label="Solar Modules"
fullWidth
variant="outlined"
value={numberValue(data.publish_time_solar)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_mixer"
label="Mixer Modules"
fullWidth
variant="outlined"
value={numberValue(data.publish_time_mixer)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_sensor"
label="Temperature Sensors"
fullWidth
variant="outlined"
value={numberValue(data.publish_time_sensor)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_other"
label="Default"
fullWidth
variant="outlined"
value={numberValue(data.publish_time_other)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
Save
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="MQTT Settings" titleGutter>
{content()}
</SectionContent>
);
};
export default MqttSettingsForm;

View File

@@ -0,0 +1,142 @@
import { FC } from 'react';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, Theme, useTheme } from '@mui/material';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import RefreshIcon from '@mui/icons-material/Refresh';
import ReportIcon from '@mui/icons-material/Report';
import SpeakerNotesOffIcon from '@mui/icons-material/SpeakerNotesOff';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { MqttStatus, MqttDisconnectReason } from '../../types';
import * as MqttApi from '../../api/mqtt';
import { useRest } from '../../utils';
export const mqttStatusHighlight = ({ enabled, connected }: MqttStatus, theme: Theme) => {
if (!enabled) {
return theme.palette.info.main;
}
if (connected) {
return theme.palette.success.main;
}
return theme.palette.error.main;
};
export const mqttPublishHighlight = ({ mqtt_fails }: MqttStatus, theme: Theme) => {
if (mqtt_fails === 0) return theme.palette.success.main;
if (mqtt_fails < 10) return theme.palette.warning.main;
return theme.palette.error.main;
};
export const mqttStatus = ({ enabled, connected }: MqttStatus) => {
if (!enabled) {
return 'Not enabled';
}
if (connected) {
return 'Connected';
}
return 'Disconnected';
};
export const disconnectReason = ({ disconnect_reason }: MqttStatus) => {
switch (disconnect_reason) {
case MqttDisconnectReason.TCP_DISCONNECTED:
return 'TCP disconnected';
case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
return 'Unacceptable protocol version';
case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
return 'Client ID rejected';
case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
return 'Server unavailable';
case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
return 'Malformed credentials';
case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
return 'Not authorized';
case MqttDisconnectReason.ESP8266_NOT_ENOUGH_SPACE:
return 'Device out of memory';
case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
return 'Server fingerprint invalid';
default:
return 'Unknown';
}
};
const MqttStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<MqttStatus>({ read: MqttApi.readMqttStatus });
const theme = useTheme();
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const renderConnectionStatus = () => {
if (data.connected) {
return (
<>
<ListItem>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText primary="Client ID" secondary={data.client_id} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttPublishHighlight(data, theme) }}>
<SpeakerNotesOffIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="MQTT Publish Errors" secondary={data.mqtt_fails} />
</ListItem>
</>
);
}
return (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<ReportIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Disconnect Reason" secondary={disconnectReason(data)} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
};
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttStatusHighlight(data, theme) }}>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={mqttStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{data.enabled && renderConnectionStatus()}
</List>
<ButtonRow>
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={loadData}>
Refresh
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="MQTT Status" titleGutter>
{content()}
</SectionContent>
);
};
export default MqttStatusForm;

View File

@@ -0,0 +1,72 @@
import React, { FC, useCallback, useContext, useState } from 'react';
import { Navigate, Routes, Route, useNavigate } from 'react-router-dom';
import { Tab } from '@mui/material';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
import { WiFiNetwork } from '../../types';
import { AuthenticatedContext } from '../../contexts/authentication';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import NetworkStatusForm from './NetworkStatusForm';
import WiFiNetworkScanner from './WiFiNetworkScanner';
import NetworkSettingsForm from './NetworkSettingsForm';
const NetworkConnection: FC = () => {
useLayoutTitle('Network Connection');
const authenticatedContext = useContext(AuthenticatedContext);
const navigate = useNavigate();
const { routerTab } = useRouterTab();
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
const selectNetwork = useCallback(
(network: WiFiNetwork) => {
setSelectedNetwork(network);
navigate('settings');
},
[navigate]
);
const deselectNetwork = useCallback(() => {
setSelectedNetwork(undefined);
}, []);
return (
<WiFiConnectionContext.Provider
value={{
selectedNetwork,
selectNetwork,
deselectNetwork
}}
>
<RouterTabs value={routerTab}>
<Tab value="status" label="Network Status" />
<Tab value="scan" label="Scan WiFi Networks" disabled={!authenticatedContext.me.admin} />
<Tab value="settings" label="Network Settings" disabled={!authenticatedContext.me.admin} />
</RouterTabs>
<Routes>
<Route path="status" element={<NetworkStatusForm />} />
<Route
path="scan"
element={
<RequireAdmin>
<WiFiNetworkScanner />
</RequireAdmin>
}
/>
<Route
path="settings"
element={
<RequireAdmin>
<NetworkSettingsForm />
</RequireAdmin>
}
/>
<Route path="/*" element={<Navigate replace to="status" />} />
</Routes>
</WiFiConnectionContext.Provider>
);
};
export default NetworkConnection;

View File

@@ -0,0 +1,257 @@
import { FC, useContext, useEffect, useState } from 'react';
import {
Avatar,
Button,
Checkbox,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
Typography
} from '@mui/material';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import DeleteIcon from '@mui/icons-material/Delete';
import SaveIcon from '@mui/icons-material/Save';
import LockIcon from '@mui/icons-material/Lock';
import {
BlockFormControlLabel,
ButtonRow,
FormLoader,
SectionContent,
ValidatedPasswordField,
ValidatedTextField
} from '../../components';
import { NetworkSettings } from '../../types';
import * as NetworkApi from '../../api/network';
import { numberValue, updateValue, useRest } from '../../utils';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import { isNetworkOpen, networkSecurityMode } from './WiFiNetworkSelector';
import { ValidateFieldsError } from 'async-validator';
import { validate } from '../../validators';
import { createNetworkSettingsValidator } from '../../validators/network';
const WiFiSettingsForm: FC = () => {
const { selectedNetwork, deselectNetwork } = useContext(WiFiConnectionContext);
const [initialized, setInitialized] = useState(false);
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<NetworkSettings>({
read: NetworkApi.readNetworkSettings,
update: NetworkApi.updateNetworkSettings
});
useEffect(() => {
if (!initialized && data) {
if (selectedNetwork) {
setData({
ssid: selectedNetwork.ssid,
password: '',
hostname: data?.hostname,
static_ip_config: false,
enableIPv6: false,
bandwidth20: false,
tx_power: 20,
nosleep: false
});
}
setInitialized(true);
}
}, [initialized, setInitialized, data, setData, selectedNetwork]);
const updateFormValue = updateValue(setData);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
useEffect(() => deselectNetwork, [deselectNetwork]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createNetworkSettingsValidator(data), data);
saveData();
} catch (errors: any) {
setFieldErrors(errors);
}
};
return (
<>
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
WiFi
</Typography>
{selectedNetwork ? (
<List>
<ListItem>
<ListItemAvatar>
<Avatar>{isNetworkOpen(selectedNetwork) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={selectedNetwork.ssid}
secondary={'Security: ' + networkSecurityMode(selectedNetwork) + ', Ch: ' + selectedNetwork.channel}
/>
<ListItemSecondaryAction>
<IconButton aria-label="Manual Config" onClick={deselectNetwork}>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</List>
) : (
<ValidatedTextField
fieldErrors={fieldErrors}
name="ssid"
label="SSID (leave blank to disable WiFi)"
fullWidth
variant="outlined"
value={data.ssid}
onChange={updateFormValue}
margin="normal"
/>
)}
{(!selectedNetwork || !isNetworkOpen(selectedNetwork)) && (
<ValidatedPasswordField
fieldErrors={fieldErrors}
name="password"
label="Password"
fullWidth
variant="outlined"
value={data.password}
onChange={updateFormValue}
margin="normal"
/>
)}
<ValidatedTextField
fieldErrors={fieldErrors}
name="tx_power"
label="WiFi Tx Power (dBm)"
fullWidth
variant="outlined"
value={numberValue(data.tx_power)}
onChange={updateFormValue}
type="number"
margin="normal"
/>
<BlockFormControlLabel
control={<Checkbox name="nosleep" checked={data.nosleep} onChange={updateFormValue} />}
label="Disable WiFi Sleep Mode"
/>
<BlockFormControlLabel
control={<Checkbox name="bandwidth20" checked={data.bandwidth20} onChange={updateFormValue} />}
label="Use Lower WiFi Bandwidth"
/>
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
General
</Typography>
<ValidatedTextField
fieldErrors={fieldErrors}
name="hostname"
label="Hostname"
fullWidth
variant="outlined"
value={data.hostname}
onChange={updateFormValue}
margin="normal"
/>
<BlockFormControlLabel
control={<Checkbox name="enableIPv6" checked={data.enableIPv6} onChange={updateFormValue} />}
label="Enable IPv6 support"
/>
<BlockFormControlLabel
control={<Checkbox name="static_ip_config" checked={data.static_ip_config} onChange={updateFormValue} />}
label="Use Fixed IP address"
/>
{data.static_ip_config && (
<>
<ValidatedTextField
fieldErrors={fieldErrors}
name="local_ip"
label="Local IP"
fullWidth
variant="outlined"
value={data.local_ip}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="gateway_ip"
label="Gateway"
fullWidth
variant="outlined"
value={data.gateway_ip}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="subnet_mask"
label="Subnet"
fullWidth
variant="outlined"
value={data.subnet_mask}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="dns_ip_1"
label="DNS IP #1"
fullWidth
variant="outlined"
value={data.dns_ip_1}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="dns_ip_2"
label="DNS IP #2"
fullWidth
variant="outlined"
value={data.dns_ip_2}
onChange={updateFormValue}
margin="normal"
/>
</>
)}
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
Save
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="Network Settings" titleGutter>
{content()}
</SectionContent>
);
};
export default WiFiSettingsForm;

View File

@@ -0,0 +1,179 @@
import { FC } from 'react';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, Theme, useTheme } from '@mui/material';
import SettingsInputComponentIcon from '@mui/icons-material/SettingsInputComponent';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import WifiIcon from '@mui/icons-material/Wifi';
import DnsIcon from '@mui/icons-material/Dns';
import RefreshIcon from '@mui/icons-material/Refresh';
import RouterIcon from '@mui/icons-material/Router';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { NetworkConnectionStatus, NetworkStatus } from '../../types';
import * as NetworkApi from '../../api/network';
import { useRest } from '../../utils';
const isConnected = ({ status }: NetworkStatus) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const networkStatusHighlight = ({ status }: NetworkStatus, theme: Theme) => {
switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return theme.palette.info.main;
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return theme.palette.success.main;
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return theme.palette.error.main;
default:
return theme.palette.warning.main;
}
};
const networkStatus = ({ status }: NetworkStatus) => {
switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return 'Inactive';
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return 'Idle';
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return 'Connected (WiFi)';
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return 'Connected (Wired)';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return 'Connection Failed';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return 'Connection Lost';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return 'Disconnected';
default:
return 'Unknown';
}
};
export const isWiFi = ({ status }: NetworkStatus) => status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatus) => status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const dnsServers = ({ dns_ip_1, dns_ip_2 }: NetworkStatus) => {
if (!dns_ip_1) {
return 'none';
}
return dns_ip_1 + (dns_ip_2 ? ',' + dns_ip_2 : '');
};
const IPs = (status: NetworkStatus) => {
if (!status.local_ipv6 || status.local_ipv6 === '0000:0000:0000:0000:0000:0000:0000:0000') {
return status.local_ip;
}
if (!status.local_ip || status.local_ip === '0.0.0.0') {
return status.local_ipv6;
}
return status.local_ip + ', ' + status.local_ipv6;
};
const NetworkStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<NetworkStatus>({ read: NetworkApi.readNetworkStatus });
const theme = useTheme();
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
{isWiFi(data) && <WifiIcon />}
{isEthernet(data) && <RouterIcon />}
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={networkStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{isWiFi(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="SSID" secondary={data.ssid} />
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
{isConnected(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>IP</Avatar>
</ListItemAvatar>
<ListItemText primary="IP Address" secondary={IPs(data)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="MAC Address" secondary={data.mac_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText primary="Subnet Mask" secondary={data.subnet_mask} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<SettingsInputComponentIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Gateway IP" secondary={data.gateway_ip || 'none'} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<DnsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="DNS Server IP" secondary={dnsServers(data)} />
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
</List>
<ButtonRow>
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={loadData}>
Refresh
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="Network Status" titleGutter>
{content()}
</SectionContent>
);
};
export default NetworkStatusForm;

View File

@@ -0,0 +1,11 @@
import { createContext } from 'react';
import { WiFiNetwork } from '../../types';
export interface WiFiConnectionContextValue {
selectedNetwork?: WiFiNetwork;
selectNetwork: (network: WiFiNetwork) => void;
deselectNetwork: () => void;
}
const WiFiConnectionContextDefaultValue = {} as WiFiConnectionContextValue;
export const WiFiConnectionContext = createContext(WiFiConnectionContextDefaultValue);

View File

@@ -0,0 +1,101 @@
import { useEffect, FC, useState, useCallback, useRef } from 'react';
import { useSnackbar } from 'notistack';
import { Button } from '@mui/material';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import * as NetworkApi from '../../api/network';
import { WiFiNetwork, WiFiNetworkList } from '../../types';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { extractErrorMessage } from '../../utils';
import WiFiNetworkSelector from './WiFiNetworkSelector';
const NUM_POLLS = 10;
const POLLING_FREQUENCY = 500;
const compareNetworks = (network1: WiFiNetwork, network2: WiFiNetwork) => {
if (network1.rssi < network2.rssi) return 1;
if (network1.rssi > network2.rssi) return -1;
return 0;
};
const WiFiNetworkScanner: FC = () => {
const { enqueueSnackbar } = useSnackbar();
const pollCount = useRef(0);
const [networkList, setNetworkList] = useState<WiFiNetworkList>();
const [errorMessage, setErrorMessage] = useState<string>();
const finishedWithError = useCallback(
(message: string) => {
enqueueSnackbar(message, { variant: 'error' });
setNetworkList(undefined);
setErrorMessage(message);
},
[enqueueSnackbar]
);
const pollNetworkList = useCallback(async () => {
try {
const response = await NetworkApi.listNetworks();
if (response.status === 202) {
const completedPollCount = pollCount.current + 1;
if (completedPollCount < NUM_POLLS) {
pollCount.current = completedPollCount;
setTimeout(pollNetworkList, POLLING_FREQUENCY);
} else {
finishedWithError('Device did not return network list in timely manner');
}
} else {
const newNetworkList = response.data;
newNetworkList.networks.sort(compareNetworks);
setNetworkList(newNetworkList);
}
} catch (error: any) {
finishedWithError(extractErrorMessage(error, 'Problem listing WiFi networks'));
}
}, [finishedWithError]);
const startNetworkScan = useCallback(async () => {
pollCount.current = 0;
setNetworkList(undefined);
setErrorMessage(undefined);
try {
await NetworkApi.scanNetworks();
setTimeout(pollNetworkList, POLLING_FREQUENCY);
} catch (error: any) {
finishedWithError(extractErrorMessage(error, 'Problem scanning for WiFi networks'));
}
}, [finishedWithError, pollNetworkList]);
useEffect(() => {
startNetworkScan();
}, [startNetworkScan]);
const renderNetworkScanner = () => {
if (!networkList) {
return <FormLoader message="Scanning&hellip;" errorMessage={errorMessage} />;
}
return <WiFiNetworkSelector networkList={networkList} />;
};
return (
<SectionContent title="Network Scanner">
{renderNetworkScanner()}
<ButtonRow>
<Button
startIcon={<PermScanWifiIcon />}
variant="outlined"
color="secondary"
onClick={startNetworkScan}
disabled={!errorMessage && !networkList}
>
Scan again&hellip;
</Button>
</ButtonRow>
</SectionContent>
);
};
export default WiFiNetworkScanner;

View File

@@ -0,0 +1,69 @@
import { FC, useContext } from 'react';
import { Avatar, Badge, List, ListItem, ListItemAvatar, ListItemIcon, ListItemText } from '@mui/material';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import LockIcon from '@mui/icons-material/Lock';
import WifiIcon from '@mui/icons-material/Wifi';
import { MessageBox } from '../../components';
import { WiFiEncryptionType, WiFiNetwork, WiFiNetworkList } from '../../types';
import { WiFiConnectionContext } from './WiFiConnectionContext';
interface WiFiNetworkSelectorProps {
networkList: WiFiNetworkList;
}
export const isNetworkOpen = ({ encryption_type }: WiFiNetwork) =>
encryption_type === WiFiEncryptionType.WIFI_AUTH_OPEN;
export const networkSecurityMode = ({ encryption_type }: WiFiNetwork) => {
switch (encryption_type) {
case WiFiEncryptionType.WIFI_AUTH_WEP:
case WiFiEncryptionType.WIFI_AUTH_WEP_PSK:
return 'WEP';
case WiFiEncryptionType.WIFI_AUTH_WEP2_PSK:
return 'WEP2';
case WiFiEncryptionType.WIFI_AUTH_WPA_WPA2_PSK:
return 'WPA/WEP2';
case WiFiEncryptionType.WIFI_AUTH_WPA2_ENTERPRISE:
return 'WEP2 Enterprise';
case WiFiEncryptionType.WIFI_AUTH_OPEN:
return 'None';
default:
return 'Unknown';
}
};
const WiFiNetworkSelector: FC<WiFiNetworkSelectorProps> = ({ networkList }) => {
const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = (network: WiFiNetwork) => {
return (
<ListItem key={network.bssid} button onClick={() => wifiConnectionContext.selectNetwork(network)}>
<ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={'Security: ' + networkSecurityMode(network) + ', Ch: ' + network.channel}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + 'db'}>
<WifiIcon />
</Badge>
</ListItemIcon>
</ListItem>
);
};
if (networkList.networks.length === 0) {
return <MessageBox mt={2} mb={1} message="No WiFi networks found" level="info" />;
}
return <List>{networkList.networks.map(renderNetwork)}</List>;
};
export default WiFiNetworkSelector;

View File

@@ -0,0 +1,101 @@
import { FC, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { Button, Checkbox, MenuItem } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import { validate } from '../../validators';
import { BlockFormControlLabel, ButtonRow, FormLoader, SectionContent, ValidatedTextField } from '../../components';
import { NTPSettings } from '../../types';
import { updateValue, useRest } from '../../utils';
import * as NTPApi from '../../api/ntp';
import { selectedTimeZone, timeZoneSelectItems, TIME_ZONES } from './TZ';
import { NTP_SETTINGS_VALIDATOR } from '../../validators/ntp';
const NTPSettingsForm: FC = () => {
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<NTPSettings>({
read: NTPApi.readNTPSettings,
update: NTPApi.updateNTPSettings
});
const updateFormValue = updateValue(setData);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(NTP_SETTINGS_VALIDATOR, data);
saveData();
} catch (errors: any) {
setFieldErrors(errors);
}
};
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
setData({
...data,
tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value]
});
};
return (
<>
<BlockFormControlLabel
control={<Checkbox name="enabled" checked={data.enabled} onChange={updateFormValue} />}
label="Enable NTP"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="server"
label="Server"
fullWidth
variant="outlined"
value={data.server}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="tz_label"
label="Time zone"
fullWidth
variant="outlined"
value={selectedTimeZone(data.tz_label, data.tz_format)}
onChange={changeTimeZone}
margin="normal"
select
>
<MenuItem disabled>Time zone...</MenuItem>
{timeZoneSelectItems()}
</ValidatedTextField>
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
Save
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="NTP Settings" titleGutter>
{content()}
</SectionContent>
);
};
export default NTPSettingsForm;

View File

@@ -0,0 +1,218 @@
import { FC, useContext, useState } from 'react';
import { useSnackbar } from 'notistack';
import {
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
TextField,
Theme,
useTheme
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
import UpdateIcon from '@mui/icons-material/Update';
import DnsIcon from '@mui/icons-material/Dns';
import AvTimerIcon from '@mui/icons-material/AvTimer';
import CancelIcon from '@mui/icons-material/Cancel';
import * as NTPApi from '../../api/ntp';
import { NTPStatus, NTPSyncStatus } from '../../types';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { extractErrorMessage, formatDateTime, formatDuration, formatLocalDateTime, useRest } from '../../utils';
import { AuthenticatedContext } from '../../contexts/authentication';
export const isNtpActive = ({ status }: NTPStatus) => status === NTPSyncStatus.NTP_ACTIVE;
export const ntpStatusHighlight = ({ status }: NTPStatus, theme: Theme) => {
switch (status) {
case NTPSyncStatus.NTP_INACTIVE:
return theme.palette.info.main;
case NTPSyncStatus.NTP_ACTIVE:
return theme.palette.success.main;
default:
return theme.palette.error.main;
}
};
export const ntpStatus = ({ status }: NTPStatus) => {
switch (status) {
case NTPSyncStatus.NTP_INACTIVE:
return 'Inactive';
case NTPSyncStatus.NTP_ACTIVE:
return 'Active';
default:
return 'Unknown';
}
};
const NTPStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<NTPStatus>({ read: NTPApi.readNTPStatus });
const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const { enqueueSnackbar } = useSnackbar();
const { me } = useContext(AuthenticatedContext);
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value);
const openSetTime = () => {
setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true);
};
const theme = useTheme();
const configureTime = async () => {
setProcessing(true);
try {
await NTPApi.updateTime({
local_time: formatLocalDateTime(new Date(localTime))
});
enqueueSnackbar('Time set', { variant: 'success' });
setSettingTime(false);
loadData();
} catch (error: any) {
enqueueSnackbar(extractErrorMessage(error, 'Problem updating time'), { variant: 'error' });
} finally {
setProcessing(false);
}
};
const renderSetTimeDialog = () => {
return (
<Dialog open={settingTime} onClose={() => setSettingTime(false)}>
<DialogTitle>Set Time</DialogTitle>
<DialogContent dividers>
<Box mb={2}>Enter local date and time below to set the device's time.</Box>
<TextField
label="Local Time"
type="datetime-local"
value={localTime}
onChange={updateLocalTime}
disabled={processing}
variant="outlined"
fullWidth
InputLabelProps={{
shrink: true
}}
/>
</DialogContent>
<DialogActions>
<Button startIcon={<CancelIcon />} variant="outlined" onClick={() => setSettingTime(false)} color="secondary">
Cancel
</Button>
<Button
startIcon={<AccessTimeIcon />}
variant="outlined"
onClick={configureTime}
disabled={processing}
color="primary"
autoFocus
>
Set Time
</Button>
</DialogActions>
</Dialog>
);
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: ntpStatusHighlight(data, theme) }}>
<UpdateIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={ntpStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{isNtpActive(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<DnsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="NTP Server" secondary={data.server} />
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>
<AccessTimeIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Local Time" secondary={formatDateTime(data.local_time)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<SwapVerticalCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="UTC Time" secondary={formatDateTime(data.utc_time)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<AvTimerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Uptime" secondary={formatDuration(data.uptime)} />
</ListItem>
<Divider variant="inset" component="li" />
</List>
<Box display="flex" flexWrap="wrap">
<Box flexGrow={1}>
<ButtonRow>
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={loadData}>
Refresh
</Button>
</ButtonRow>
</Box>
{me.admin && data && !isNtpActive(data) && (
<Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow>
<Button onClick={openSetTime} variant="outlined" color="primary" startIcon={<AccessTimeIcon />}>
Set Time
</Button>
</ButtonRow>
</Box>
)}
</Box>
{renderSetTimeDialog()}
</>
);
};
return (
<SectionContent title="NTP Status" titleGutter>
{content()}
</SectionContent>
);
};
export default NTPStatusForm;

View File

@@ -0,0 +1,40 @@
import React, { FC, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Tab } from '@mui/material';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
import { AuthenticatedContext } from '../../contexts/authentication';
import NTPStatusForm from './NTPStatusForm';
import NTPSettingsForm from './NTPSettingsForm';
const NetworkTime: FC = () => {
useLayoutTitle('Network Time');
const authenticatedContext = useContext(AuthenticatedContext);
const { routerTab } = useRouterTab();
return (
<>
<RouterTabs value={routerTab}>
<Tab value="status" label="NTP Status" />
<Tab value="settings" label="NTP Settings" disabled={!authenticatedContext.me.admin} />
</RouterTabs>
<Routes>
<Route path="status" element={<NTPStatusForm />} />
<Route
path="settings"
element={
<RequireAdmin>
<NTPSettingsForm />
</RequireAdmin>
}
/>
<Route path="/*" element={<Navigate replace to="status" />} />
</Routes>
</>
);
};
export default NetworkTime;

View File

@@ -1,4 +1,4 @@
import MenuItem from '@material-ui/core/MenuItem';
import { MenuItem } from '@mui/material';
type TimeZones = {
[name: string]: string;

Some files were not shown because too many files have changed in this diff Show More