Deleted .idea/.gitignore, .idea/clion.iml, .idea/misc.xml, .idea/modules.xml, .idea/platformio.iml, .idea/serialmonitor_settings.xml, .idea/vcs.xml, .idea/watcherTasks.xml files
This commit is contained in:
50
interface/src/App.tsx
Normal file
50
interface/src/App.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
import React, { Component, RefObject } from 'react';
|
||||
import { Redirect, Route, Switch } from 'react-router';
|
||||
import { SnackbarProvider } from 'notistack';
|
||||
|
||||
import { IconButton } from '@material-ui/core';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
|
||||
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="/" />;
|
||||
|
||||
class App extends Component {
|
||||
|
||||
notistackRef: RefObject<any> = React.createRef();
|
||||
|
||||
componentDidMount() {
|
||||
document.title = PROJECT_NAME;
|
||||
}
|
||||
|
||||
onClickDismiss = (key: string | number | undefined) => () => {
|
||||
this.notistackRef.current.closeSnackbar(key);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CustomMuiTheme>
|
||||
<SnackbarProvider maxSnack={3} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
|
||||
ref={this.notistackRef}
|
||||
action={(key) => (
|
||||
<IconButton onClick={this.onClickDismiss(key)} size="small">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
)}>
|
||||
<FeaturesWrapper>
|
||||
<Switch>
|
||||
<Route exact path="/unauthorized" component={unauthorizedRedirect} />
|
||||
<Route component={AppRouting} />
|
||||
</Switch>
|
||||
</FeaturesWrapper>
|
||||
</SnackbarProvider>
|
||||
</CustomMuiTheme>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default App
|
60
interface/src/AppRouting.tsx
Normal file
60
interface/src/AppRouting.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Switch, Redirect } from 'react-router';
|
||||
|
||||
import * as Authentication from './authentication/Authentication';
|
||||
import AuthenticationWrapper from './authentication/AuthenticationWrapper';
|
||||
import UnauthenticatedRoute from './authentication/UnauthenticatedRoute';
|
||||
import AuthenticatedRoute from './authentication/AuthenticatedRoute';
|
||||
|
||||
import SignIn from './SignIn';
|
||||
import ProjectRouting from './project/ProjectRouting';
|
||||
import WiFiConnection from './wifi/WiFiConnection';
|
||||
import AccessPoint from './ap/AccessPoint';
|
||||
import NetworkTime from './ntp/NetworkTime';
|
||||
import Security from './security/Security';
|
||||
import System from './system/System';
|
||||
|
||||
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}/` : "/wifi/";
|
||||
|
||||
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="/wifi/*" component={WiFiConnection} />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withFeatures(AppRouting);
|
39
interface/src/CustomMuiTheme.tsx
Normal file
39
interface/src/CustomMuiTheme.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { CssBaseline } from '@material-ui/core';
|
||||
import { MuiThemeProvider, createMuiTheme, StylesProvider } from '@material-ui/core/styles';
|
||||
import { blueGrey, indigo, orange, red, green } from '@material-ui/core/colors';
|
||||
|
||||
const theme = createMuiTheme({
|
||||
palette: {
|
||||
primary: indigo,
|
||||
secondary: blueGrey,
|
||||
info: {
|
||||
main: blueGrey[900]
|
||||
},
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
147
interface/src/SignIn.tsx
Normal file
147
interface/src/SignIn.tsx
Normal file
@ -0,0 +1,147 @@
|
||||
import React, { Component } from 'react';
|
||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
||||
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
|
||||
|
||||
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 { withAuthenticationContext, AuthenticationContextProps } from './authentication/AuthenticationContext';
|
||||
import {PasswordValidator} from './components';
|
||||
import { PROJECT_NAME, SIGN_IN_ENDPOINT } from './api';
|
||||
|
||||
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),
|
||||
}
|
||||
});
|
||||
|
||||
type SignInProps = WithSnackbarProps & WithStyles<typeof styles> & AuthenticationContextProps;
|
||||
|
||||
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,
|
||||
}))
|
||||
};
|
||||
|
||||
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 });
|
||||
});
|
||||
};
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withAuthenticationContext(withSnackbar(withStyles(styles)(SignIn)));
|
5
interface/src/ap/APModes.ts
Normal file
5
interface/src/ap/APModes.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { APSettings, APProvisionMode } from "./types";
|
||||
|
||||
export const isAPEnabled = ({ provision_mode }: APSettings) => {
|
||||
return provision_mode === APProvisionMode.AP_MODE_ALWAYS || provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
|
||||
}
|
30
interface/src/ap/APSettingsController.tsx
Normal file
30
interface/src/ap/APSettingsController.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React, { 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);
|
106
interface/src/ap/APSettingsForm.tsx
Normal file
106
interface/src/ap/APSettingsForm.tsx
Normal file
@ -0,0 +1,106 @@
|
||||
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> {
|
||||
|
||||
componentWillMount() {
|
||||
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…"
|
||||
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 WiFi 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;
|
28
interface/src/ap/APStatus.ts
Normal file
28
interface/src/ap/APStatus.ts
Normal file
@ -0,0 +1,28 @@
|
||||
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";
|
||||
}
|
||||
};
|
29
interface/src/ap/APStatusController.tsx
Normal file
29
interface/src/ap/APStatusController.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React, { 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);
|
78
interface/src/ap/APStatusForm.tsx
Normal file
78
interface/src/ap/APStatusForm.tsx
Normal file
@ -0,0 +1,78 @@
|
||||
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);
|
38
interface/src/ap/AccessPoint.tsx
Normal file
38
interface/src/ap/AccessPoint.tsx
Normal file
@ -0,0 +1,38 @@
|
||||
import React, { 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 = (event: React.ChangeEvent<{}>, path: string) => {
|
||||
this.props.history.push(path);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { authenticatedContext } = this.props;
|
||||
return (
|
||||
<MenuAppBar sectionTitle="Access Point">
|
||||
<Tabs value={this.props.match.url} onChange={this.handleTabChange} 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);
|
27
interface/src/ap/types.ts
Normal file
27
interface/src/ap/types.ts
Normal file
@ -0,0 +1,27 @@
|
||||
export enum APProvisionMode {
|
||||
AP_MODE_ALWAYS = 0,
|
||||
AP_MODE_DISCONNECTED = 1,
|
||||
AP_NEVER = 2
|
||||
}
|
||||
|
||||
export enum APNetworkStatus {
|
||||
ACTIVE = 0,
|
||||
INACTIVE = 1,
|
||||
LINGERING = 2
|
||||
}
|
||||
|
||||
export interface APStatus {
|
||||
status: APNetworkStatus;
|
||||
ip_address: string;
|
||||
mac_address: string;
|
||||
station_num: number;
|
||||
}
|
||||
|
||||
export interface APSettings {
|
||||
provision_mode: APProvisionMode;
|
||||
ssid: string;
|
||||
password: string;
|
||||
local_ip: string;
|
||||
gateway_ip: string;
|
||||
subnet_mask: string;
|
||||
}
|
22
interface/src/api/Endpoints.ts
Normal file
22
interface/src/api/Endpoints.ts
Normal file
@ -0,0 +1,22 @@
|
||||
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 WIFI_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "wifiSettings";
|
||||
export const WIFI_STATUS_ENDPOINT = ENDPOINT_ROOT + "wifiStatus";
|
||||
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 RESTART_ENDPOINT = ENDPOINT_ROOT + "restart";
|
||||
export const FACTORY_RESET_ENDPOINT = ENDPOINT_ROOT + "factoryReset";
|
24
interface/src/api/Env.ts
Normal file
24
interface/src/api/Env.ts
Normal file
@ -0,0 +1,24 @@
|
||||
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/");
|
||||
|
||||
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;
|
||||
}
|
2
interface/src/api/index.ts
Normal file
2
interface/src/api/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './Env'
|
||||
export * from './Endpoints'
|
42
interface/src/authentication/AuthenticatedRoute.tsx
Normal file
42
interface/src/authentication/AuthenticatedRoute.tsx
Normal file
@ -0,0 +1,42 @@
|
||||
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 } from './AuthenticationContext';
|
||||
|
||||
type ChildComponent = React.ComponentType<RouteComponentProps<any>> | React.ComponentType<any>;
|
||||
|
||||
interface AuthenticatedRouteProps extends RouteProps, WithSnackbarProps, AuthenticationContextProps {
|
||||
component: ChildComponent;
|
||||
}
|
||||
|
||||
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 AuthenticatedContext}>
|
||||
<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));
|
114
interface/src/authentication/Authentication.ts
Normal file
114
interface/src/authentication/Authentication.ts
Normal file
@ -0,0 +1,114 @@
|
||||
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 routene 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 Authroization header and redirecting on
|
||||
* authroization 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 (event: ProgressEvent<EventTarget>) {
|
||||
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 routene 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();
|
||||
}
|
59
interface/src/authentication/AuthenticationContext.tsx
Normal file
59
interface/src/authentication/AuthenticationContext.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface Me {
|
||||
username: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface AuthenticationContext {
|
||||
refresh: () => void;
|
||||
signIn: (accessToken: string) => void;
|
||||
signOut: () => void;
|
||||
me?: Me;
|
||||
}
|
||||
|
||||
const AuthenticationContextDefaultValue = {} as AuthenticationContext
|
||||
export const AuthenticationContext = React.createContext(
|
||||
AuthenticationContextDefaultValue
|
||||
);
|
||||
|
||||
export interface AuthenticationContextProps {
|
||||
authenticationContext: AuthenticationContext;
|
||||
}
|
||||
|
||||
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 AuthenticatedContext extends AuthenticationContext {
|
||||
me: Me;
|
||||
}
|
||||
|
||||
const AuthenticatedContextDefaultValue = {} as AuthenticatedContext
|
||||
export const AuthenticatedContext = React.createContext(
|
||||
AuthenticatedContextDefaultValue
|
||||
);
|
||||
|
||||
export interface AuthenticatedContextProps {
|
||||
authenticatedContext: AuthenticatedContext;
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
109
interface/src/authentication/AuthenticationWrapper.tsx
Normal file
109
interface/src/authentication/AuthenticationWrapper.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
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, 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: AuthenticationContext;
|
||||
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 = () => {
|
||||
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))
|
30
interface/src/authentication/UnauthenticatedRoute.tsx
Normal file
30
interface/src/authentication/UnauthenticatedRoute.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
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>;
|
||||
}
|
||||
|
||||
type RenderComponent = (props: RouteComponentProps<any>) => React.ReactNode;
|
||||
|
||||
class UnauthenticatedRoute extends Route<UnauthenticatedRouteProps> {
|
||||
|
||||
public render() {
|
||||
const { authenticationContext, component: Component, features, ...rest } = this.props;
|
||||
const renderComponent: RenderComponent = (props) => {
|
||||
if (authenticationContext.me) {
|
||||
return (<Redirect to={Authentication.fetchLoginRedirect(features)} />);
|
||||
}
|
||||
return (<Component {...props} />);
|
||||
}
|
||||
return (
|
||||
<Route {...rest} render={renderComponent} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withFeatures(withAuthenticationContext(UnauthenticatedRoute));
|
6
interface/src/authentication/index.ts
Normal file
6
interface/src/authentication/index.ts
Normal file
@ -0,0 +1,6 @@
|
||||
export { default as AuthenticatedRoute } from './AuthenticatedRoute';
|
||||
export { default as AuthenticationWrapper } from './AuthenticationWrapper';
|
||||
export { default as UnauthenticatedRoute } from './UnauthenticatedRoute';
|
||||
|
||||
export * from './Authentication';
|
||||
export * from './AuthenticationContext';
|
59
interface/src/components/ApplicationError.tsx
Normal file
59
interface/src/components/ApplicationError.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
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;
|
10
interface/src/components/BlockFormControlLabel.tsx
Normal file
10
interface/src/components/BlockFormControlLabel.tsx
Normal file
@ -0,0 +1,10 @@
|
||||
import React, { FC } from "react";
|
||||
import { FormControlLabel, FormControlLabelProps } from "@material-ui/core";
|
||||
|
||||
const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
|
||||
<div>
|
||||
<FormControlLabel {...props} />
|
||||
</div>
|
||||
)
|
||||
|
||||
export default BlockFormControlLabel;
|
11
interface/src/components/ErrorButton.tsx
Normal file
11
interface/src/components/ErrorButton.tsx
Normal file
@ -0,0 +1,11 @@
|
||||
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;
|
7
interface/src/components/FormActions.tsx
Normal file
7
interface/src/components/FormActions.tsx
Normal file
@ -0,0 +1,7 @@
|
||||
import { styled, Box } from "@material-ui/core";
|
||||
|
||||
const FormActions = styled(Box)(({ theme }) => ({
|
||||
marginTop: theme.spacing(1)
|
||||
}));
|
||||
|
||||
export default FormActions;
|
13
interface/src/components/FormButton.tsx
Normal file
13
interface/src/components/FormButton.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
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;
|
32
interface/src/components/FullScreenLoading.tsx
Normal file
32
interface/src/components/FullScreenLoading.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
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…
|
||||
</Typography>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FullScreenLoading;
|
23
interface/src/components/HighlightAvatar.tsx
Normal file
23
interface/src/components/HighlightAvatar.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { Avatar, makeStyles } from "@material-ui/core";
|
||||
import React, { 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;
|
286
interface/src/components/MenuAppBar.tsx
Normal file
286
interface/src/components/MenuAppBar.tsx
Normal file
@ -0,0 +1,286 @@
|
||||
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 WifiIcon from '@material-ui/icons/Wifi';
|
||||
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='/wifi/' selected={path.startsWith('/wifi/')} button component={Link}>
|
||||
<ListItemIcon>
|
||||
<WifiIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="WiFi 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)
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
58
interface/src/components/PasswordValidator.tsx
Normal file
58
interface/src/components/PasswordValidator.tsx
Normal file
@ -0,0 +1,58 @@
|
||||
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);
|
113
interface/src/components/RestController.tsx
Normal file
113
interface/src/components/RestController.tsx
Normal file
@ -0,0 +1,113 @@
|
||||
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}
|
||||
/>;
|
||||
}
|
||||
|
||||
});
|
||||
}
|
56
interface/src/components/RestFormLoader.tsx
Normal file
56
interface/src/components/RestFormLoader.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
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…
|
||||
</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 });
|
||||
}
|
33
interface/src/components/SectionContent.tsx
Normal file
33
interface/src/components/SectionContent.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React 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),
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
interface SectionContentProps {
|
||||
title: string;
|
||||
titleGutter?: boolean;
|
||||
}
|
||||
|
||||
const SectionContent: React.FC<SectionContentProps> = (props) => {
|
||||
const { children, title, titleGutter } = props;
|
||||
const classes = useStyles();
|
||||
return (
|
||||
<Paper className={classes.content}>
|
||||
<Typography variant="h6" gutterBottom={titleGutter}>
|
||||
{title}
|
||||
</Typography>
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export default SectionContent;
|
96
interface/src/components/SingleUpload.tsx
Normal file
96
interface/src/components/SingleUpload.tsx
Normal file
@ -0,0 +1,96 @@
|
||||
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;
|
133
interface/src/components/WebSocketController.tsx
Normal file
133
interface/src/components/WebSocketController.tsx
Normal file
@ -0,0 +1,133 @@
|
||||
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>) => {
|
||||
switch (message.type) {
|
||||
case WebSocketMessageType.ID:
|
||||
this.setState({ clientId: message.id });
|
||||
break;
|
||||
case WebSocketMessageType.PAYLOAD:
|
||||
const { clientId, data } = this.state;
|
||||
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}
|
||||
/>;
|
||||
}
|
||||
|
||||
});
|
||||
}
|
40
interface/src/components/WebSocketFormLoader.tsx
Normal file
40
interface/src/components/WebSocketFormLoader.tsx
Normal file
@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
|
||||
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 });
|
||||
}
|
17
interface/src/components/index.ts
Normal file
17
interface/src/components/index.ts
Normal file
@ -0,0 +1,17 @@
|
||||
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 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';
|
23
interface/src/features/ApplicationContext.tsx
Normal file
23
interface/src/features/ApplicationContext.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import { Features } from './types';
|
||||
|
||||
export interface ApplicationContext {
|
||||
features: Features;
|
||||
}
|
||||
|
||||
const ApplicationContextDefaultValue = {} as ApplicationContext
|
||||
export const ApplicationContext = React.createContext(
|
||||
ApplicationContextDefaultValue
|
||||
);
|
||||
|
||||
export function withAuthenticatedContexApplicationContext<T extends ApplicationContext>(Component: React.ComponentType<T>) {
|
||||
return class extends React.Component<Omit<T, keyof ApplicationContext>> {
|
||||
render() {
|
||||
return (
|
||||
<ApplicationContext.Consumer>
|
||||
{authenticatedContext => <Component {...this.props as T} features={authenticatedContext} />}
|
||||
</ApplicationContext.Consumer>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
27
interface/src/features/FeaturesContext.tsx
Normal file
27
interface/src/features/FeaturesContext.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { Features } from './types';
|
||||
|
||||
export interface FeaturesContext {
|
||||
features: Features;
|
||||
}
|
||||
|
||||
const FeaturesContextDefaultValue = {} as FeaturesContext
|
||||
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>
|
||||
);
|
||||
}
|
||||
};
|
||||
}
|
61
interface/src/features/FeaturesWrapper.tsx
Normal file
61
interface/src/features/FeaturesWrapper.tsx
Normal file
@ -0,0 +1,61 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { Features } from './types';
|
||||
import { FeaturesContext } from './FeaturesContext';
|
||||
import FullScreenLoading from '../components/FullScreenLoading';
|
||||
import ApplicationError from '../components/ApplicationError';
|
||||
import { FEATURES_ENDPOINT } from '../api';
|
||||
|
||||
interface FeaturesWrapperState {
|
||||
features?: Features;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
class FeaturesWrapper extends Component<{}, FeaturesWrapperState> {
|
||||
|
||||
state: FeaturesWrapperState = {};
|
||||
|
||||
componentDidMount() {
|
||||
this.fetchFeaturesDetails();
|
||||
}
|
||||
|
||||
fetchFeaturesDetails = () => {
|
||||
fetch(FEATURES_ENDPOINT)
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
return response.json();
|
||||
} else {
|
||||
throw Error("Unexpected status code: " + response.status);
|
||||
}
|
||||
}).then(features => {
|
||||
this.setState({ features });
|
||||
})
|
||||
.catch(error => {
|
||||
this.setState({ error: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { features, error } = this.state;
|
||||
if (features) {
|
||||
return (
|
||||
<FeaturesContext.Provider value={{
|
||||
features
|
||||
}}>
|
||||
{this.props.children}
|
||||
</FeaturesContext.Provider>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<ApplicationError error={error} />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FullScreenLoading />
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default FeaturesWrapper;
|
8
interface/src/features/types.ts
Normal file
8
interface/src/features/types.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export interface Features {
|
||||
project: boolean;
|
||||
security: boolean;
|
||||
mqtt: boolean;
|
||||
ntp: boolean;
|
||||
ota: boolean;
|
||||
upload_firmware: boolean;
|
||||
}
|
5
interface/src/history.ts
Normal file
5
interface/src/history.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import { createBrowserHistory } from 'history';
|
||||
|
||||
export default createBrowserHistory({
|
||||
/* pass a configuration object here if needed */
|
||||
})
|
13
interface/src/index.tsx
Normal file
13
interface/src/index.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { render } from 'react-dom';
|
||||
|
||||
import history from './history';
|
||||
import { Router } from 'react-router';
|
||||
|
||||
import App from './App';
|
||||
|
||||
render((
|
||||
<Router history={history}>
|
||||
<App/>
|
||||
</Router>
|
||||
), document.getElementById("root"))
|
37
interface/src/mqtt/Mqtt.tsx
Normal file
37
interface/src/mqtt/Mqtt.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React, { 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 MqttStatusController from './MqttStatusController';
|
||||
import MqttSettingsController from './MqttSettingsController';
|
||||
|
||||
type MqttProps = AuthenticatedContextProps & RouteComponentProps;
|
||||
|
||||
class Mqtt extends Component<MqttProps> {
|
||||
|
||||
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
|
||||
this.props.history.push(path);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { authenticatedContext } = this.props;
|
||||
return (
|
||||
<MenuAppBar sectionTitle="MQTT">
|
||||
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth">
|
||||
<Tab value="/mqtt/status" label="MQTT Status" />
|
||||
<Tab value="/mqtt/settings" label="MQTT Settings" disabled={!authenticatedContext.me.admin} />
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<AuthenticatedRoute exact path="/mqtt/status" component={MqttStatusController} />
|
||||
<AuthenticatedRoute exact path="/mqtt/settings" component={MqttSettingsController} />
|
||||
<Redirect to="/mqtt/status" />
|
||||
</Switch>
|
||||
</MenuAppBar>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withAuthenticatedContext(Mqtt);
|
30
interface/src/mqtt/MqttSettingsController.tsx
Normal file
30
interface/src/mqtt/MqttSettingsController.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
|
||||
import { MQTT_SETTINGS_ENDPOINT } from '../api';
|
||||
|
||||
import MqttSettingsForm from './MqttSettingsForm';
|
||||
import { MqttSettings } from './types';
|
||||
|
||||
type MqttSettingsControllerProps = RestControllerProps<MqttSettings>;
|
||||
|
||||
class MqttSettingsController extends Component<MqttSettingsControllerProps> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title="MQTT Settings" titleGutter>
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={formProps => <MqttSettingsForm {...formProps} />}
|
||||
/>
|
||||
</SectionContent>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default restController(MQTT_SETTINGS_ENDPOINT, MqttSettingsController);
|
128
interface/src/mqtt/MqttSettingsForm.tsx
Normal file
128
interface/src/mqtt/MqttSettingsForm.tsx
Normal file
@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
|
||||
|
||||
import { Checkbox, TextField } from '@material-ui/core';
|
||||
import SaveIcon from '@material-ui/icons/Save';
|
||||
|
||||
import { RestFormProps, FormActions, FormButton, BlockFormControlLabel, PasswordValidator } from '../components';
|
||||
import { isIP, isHostname, or } from '../validators';
|
||||
|
||||
import { MqttSettings } from './types';
|
||||
|
||||
type MqttSettingsFormProps = RestFormProps<MqttSettings>;
|
||||
|
||||
class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
|
||||
|
||||
componentDidMount() {
|
||||
ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { data, handleValueChange, saveData } = this.props;
|
||||
return (
|
||||
<ValidatorForm onSubmit={saveData}>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={data.enabled}
|
||||
onChange={handleValueChange('enabled')}
|
||||
value="enabled"
|
||||
/>
|
||||
}
|
||||
label="Enable MQTT?"
|
||||
/>
|
||||
<TextValidator
|
||||
validators={['required', 'isIPOrHostname']}
|
||||
errorMessages={['Host is required', "Not a valid IP address or hostname"]}
|
||||
name="host"
|
||||
label="Host"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.host}
|
||||
onChange={handleValueChange('host')}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextValidator
|
||||
validators={['required', 'isNumber', 'minNumber:0', 'maxNumber:65535']}
|
||||
errorMessages={['Port is required', "Must be a number", "Must be greater than 0 ", "Max value is 65535"]}
|
||||
name="port"
|
||||
label="Port"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.port}
|
||||
type="number"
|
||||
onChange={handleValueChange('port')}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
name="username"
|
||||
label="Username"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.username}
|
||||
onChange={handleValueChange('username')}
|
||||
margin="normal"
|
||||
/>
|
||||
<PasswordValidator
|
||||
name="password"
|
||||
label="Password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.password}
|
||||
onChange={handleValueChange('password')}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextField
|
||||
name="client_id"
|
||||
label="Client ID (optional)"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.client_id}
|
||||
onChange={handleValueChange('client_id')}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextValidator
|
||||
validators={['required', 'isNumber', 'minNumber:1', 'maxNumber:65535']}
|
||||
errorMessages={['Keep alive is required', "Must be a number", "Must be greater than 0", "Max value is 65535"]}
|
||||
name="keep_alive"
|
||||
label="Keep Alive (seconds)"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.keep_alive}
|
||||
type="number"
|
||||
onChange={handleValueChange('keep_alive')}
|
||||
margin="normal"
|
||||
/>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={data.clean_session}
|
||||
onChange={handleValueChange('clean_session')}
|
||||
value="clean_session"
|
||||
/>
|
||||
}
|
||||
label="Clean Session?"
|
||||
/>
|
||||
<TextValidator
|
||||
validators={['required', 'isNumber', 'minNumber:1', 'maxNumber:65535']}
|
||||
errorMessages={['Max topic length is required', "Must be a number", "Must be greater than 0", "Max value is 65535"]}
|
||||
name="max_topic_length"
|
||||
label="Max Topic Length"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.max_topic_length}
|
||||
type="number"
|
||||
onChange={handleValueChange('max_topic_length')}
|
||||
margin="normal"
|
||||
/>
|
||||
<FormActions>
|
||||
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit">
|
||||
Save
|
||||
</FormButton>
|
||||
</FormActions>
|
||||
</ValidatorForm>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default MqttSettingsForm;
|
45
interface/src/mqtt/MqttStatus.ts
Normal file
45
interface/src/mqtt/MqttStatus.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import { Theme } from "@material-ui/core";
|
||||
import { MqttStatus, MqttDisconnectReason } from "./types";
|
||||
|
||||
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 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"
|
||||
}
|
||||
}
|
29
interface/src/mqtt/MqttStatusController.tsx
Normal file
29
interface/src/mqtt/MqttStatusController.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
|
||||
import { MQTT_STATUS_ENDPOINT } from '../api';
|
||||
|
||||
import MqttStatusForm from './MqttStatusForm';
|
||||
import { MqttStatus } from './types';
|
||||
|
||||
type MqttStatusControllerProps = RestControllerProps<MqttStatus>;
|
||||
|
||||
class MqttStatusController extends Component<MqttStatusControllerProps> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title="MQTT Status">
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={formProps => <MqttStatusForm {...formProps} />}
|
||||
/>
|
||||
</SectionContent>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default restController(MQTT_STATUS_ENDPOINT, MqttStatusController);
|
83
interface/src/mqtt/MqttStatusForm.tsx
Normal file
83
interface/src/mqtt/MqttStatusForm.tsx
Normal file
@ -0,0 +1,83 @@
|
||||
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 DeviceHubIcon from '@material-ui/icons/DeviceHub';
|
||||
import RefreshIcon from '@material-ui/icons/Refresh';
|
||||
import ReportIcon from '@material-ui/icons/Report';
|
||||
|
||||
import { RestFormProps, FormActions, FormButton, HighlightAvatar } from '../components';
|
||||
import { mqttStatusHighlight, mqttStatus, disconnectReason } from './MqttStatus';
|
||||
import { MqttStatus } from './types';
|
||||
|
||||
type MqttStatusFormProps = RestFormProps<MqttStatus> & WithTheme;
|
||||
|
||||
class MqttStatusForm extends Component<MqttStatusFormProps> {
|
||||
|
||||
renderConnectionStatus() {
|
||||
const { data } = this.props
|
||||
if (data.connected) {
|
||||
return (
|
||||
<Fragment>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>#</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Client ID" secondary={data.client_id} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Fragment>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<ReportIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Disconnect Reason" secondary={disconnectReason(data)} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
createListItems() {
|
||||
const { data, theme } = this.props
|
||||
return (
|
||||
<Fragment>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<HighlightAvatar color={mqttStatusHighlight(data, theme)}>
|
||||
<DeviceHubIcon />
|
||||
</HighlightAvatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Status" secondary={mqttStatus(data)} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
{data.enabled && this.renderConnectionStatus()}
|
||||
</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(MqttStatusForm);
|
29
interface/src/mqtt/types.ts
Normal file
29
interface/src/mqtt/types.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export enum MqttDisconnectReason {
|
||||
TCP_DISCONNECTED = 0,
|
||||
MQTT_UNACCEPTABLE_PROTOCOL_VERSION = 1,
|
||||
MQTT_IDENTIFIER_REJECTED = 2,
|
||||
MQTT_SERVER_UNAVAILABLE = 3,
|
||||
MQTT_MALFORMED_CREDENTIALS = 4,
|
||||
MQTT_NOT_AUTHORIZED = 5,
|
||||
ESP8266_NOT_ENOUGH_SPACE = 6,
|
||||
TLS_BAD_FINGERPRINT = 7
|
||||
}
|
||||
|
||||
export interface MqttStatus {
|
||||
enabled: boolean;
|
||||
connected: boolean;
|
||||
client_id: string;
|
||||
disconnect_reason: MqttDisconnectReason;
|
||||
}
|
||||
|
||||
export interface MqttSettings {
|
||||
enabled: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
client_id: string;
|
||||
keep_alive: number;
|
||||
clean_session: boolean;
|
||||
max_topic_length: number;
|
||||
}
|
30
interface/src/ntp/NTPSettingsController.tsx
Normal file
30
interface/src/ntp/NTPSettingsController.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
|
||||
import { NTP_SETTINGS_ENDPOINT } from '../api';
|
||||
|
||||
import NTPSettingsForm from './NTPSettingsForm';
|
||||
import { NTPSettings } from './types';
|
||||
|
||||
type NTPSettingsControllerProps = RestControllerProps<NTPSettings>;
|
||||
|
||||
class NTPSettingsController extends Component<NTPSettingsControllerProps> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title="NTP Settings" titleGutter>
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={formProps => <NTPSettingsForm {...formProps} />}
|
||||
/>
|
||||
</SectionContent>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default restController(NTP_SETTINGS_ENDPOINT, NTPSettingsController);
|
80
interface/src/ntp/NTPSettingsForm.tsx
Normal file
80
interface/src/ntp/NTPSettingsForm.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
import React from 'react';
|
||||
import { TextValidator, ValidatorForm, SelectValidator } from 'react-material-ui-form-validator';
|
||||
|
||||
import { Checkbox, MenuItem } from '@material-ui/core';
|
||||
import SaveIcon from '@material-ui/icons/Save';
|
||||
|
||||
import { RestFormProps, FormActions, FormButton, BlockFormControlLabel } from '../components';
|
||||
import { isIP, isHostname, or } from '../validators';
|
||||
|
||||
import { TIME_ZONES, timeZoneSelectItems, selectedTimeZone } from './TZ';
|
||||
import { NTPSettings } from './types';
|
||||
|
||||
type NTPSettingsFormProps = RestFormProps<NTPSettings>;
|
||||
|
||||
class NTPSettingsForm extends React.Component<NTPSettingsFormProps> {
|
||||
|
||||
componentDidMount() {
|
||||
ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
|
||||
}
|
||||
|
||||
changeTimeZone = (event: React.ChangeEvent<HTMLSelectElement>) => {
|
||||
const { data, setData } = this.props;
|
||||
setData({
|
||||
...data,
|
||||
tz_label: event.target.value,
|
||||
tz_format: TIME_ZONES[event.target.value]
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const { data, handleValueChange, saveData } = this.props;
|
||||
return (
|
||||
<ValidatorForm onSubmit={saveData}>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={data.enabled}
|
||||
onChange={handleValueChange('enabled')}
|
||||
value="enabled"
|
||||
/>
|
||||
}
|
||||
label="Enable NTP?"
|
||||
/>
|
||||
<TextValidator
|
||||
validators={['required', 'isIPOrHostname']}
|
||||
errorMessages={['Server is required', "Not a valid IP address or hostname"]}
|
||||
name="server"
|
||||
label="Server"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.server}
|
||||
onChange={handleValueChange('server')}
|
||||
margin="normal"
|
||||
/>
|
||||
<SelectValidator
|
||||
validators={['required']}
|
||||
errorMessages={['Time zone is required']}
|
||||
name="tz_label"
|
||||
label="Time zone"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
native="true"
|
||||
value={selectedTimeZone(data.tz_label, data.tz_format)}
|
||||
onChange={this.changeTimeZone}
|
||||
margin="normal"
|
||||
>
|
||||
<MenuItem disabled>Time zone...</MenuItem>
|
||||
{timeZoneSelectItems()}
|
||||
</SelectValidator>
|
||||
<FormActions>
|
||||
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit">
|
||||
Save
|
||||
</FormButton>
|
||||
</FormActions>
|
||||
</ValidatorForm>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default NTPSettingsForm;
|
26
interface/src/ntp/NTPStatus.ts
Normal file
26
interface/src/ntp/NTPStatus.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Theme } from "@material-ui/core";
|
||||
import { NTPStatus, NTPSyncStatus } from "./types";
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
30
interface/src/ntp/NTPStatusController.tsx
Normal file
30
interface/src/ntp/NTPStatusController.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
|
||||
import { NTP_STATUS_ENDPOINT } from '../api';
|
||||
|
||||
import NTPStatusForm from './NTPStatusForm';
|
||||
import { NTPStatus } from './types';
|
||||
|
||||
type NTPStatusControllerProps = RestControllerProps<NTPStatus>;
|
||||
|
||||
class NTPStatusController extends Component<NTPStatusControllerProps> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title="NTP Status">
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={formProps => <NTPStatusForm {...formProps} />}
|
||||
/>
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default restController(NTP_STATUS_ENDPOINT, NTPStatusController);
|
198
interface/src/ntp/NTPStatusForm.tsx
Normal file
198
interface/src/ntp/NTPStatusForm.tsx
Normal file
@ -0,0 +1,198 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
import moment from 'moment';
|
||||
|
||||
import { WithTheme, withTheme } from '@material-ui/core/styles';
|
||||
import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText, Button } from '@material-ui/core';
|
||||
import { Dialog, DialogTitle, DialogContent, DialogActions, Box, TextField } from '@material-ui/core';
|
||||
|
||||
import SwapVerticalCircleIcon from '@material-ui/icons/SwapVerticalCircle';
|
||||
import AccessTimeIcon from '@material-ui/icons/AccessTime';
|
||||
import DNSIcon from '@material-ui/icons/Dns';
|
||||
import UpdateIcon from '@material-ui/icons/Update';
|
||||
import AvTimerIcon from '@material-ui/icons/AvTimer';
|
||||
import RefreshIcon from '@material-ui/icons/Refresh';
|
||||
|
||||
import { RestFormProps, FormButton, HighlightAvatar } from '../components';
|
||||
import { isNtpActive, ntpStatusHighlight, ntpStatus } from './NTPStatus';
|
||||
import { formatIsoDateTime, formatLocalDateTime } from './TimeFormat';
|
||||
import { NTPStatus, Time } from './types';
|
||||
import { redirectingAuthorizedFetch, withAuthenticatedContext, AuthenticatedContextProps } from '../authentication';
|
||||
import { TIME_ENDPOINT } from '../api';
|
||||
|
||||
type NTPStatusFormProps = RestFormProps<NTPStatus> & WithTheme & AuthenticatedContextProps;
|
||||
|
||||
interface NTPStatusFormState {
|
||||
settingTime: boolean;
|
||||
localTime: string;
|
||||
processing: boolean;
|
||||
}
|
||||
|
||||
class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
|
||||
|
||||
constructor(props: NTPStatusFormProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
settingTime: false,
|
||||
localTime: '',
|
||||
processing: false
|
||||
};
|
||||
}
|
||||
|
||||
updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ localTime: event.target.value });
|
||||
}
|
||||
|
||||
openSetTime = () => {
|
||||
this.setState({ localTime: formatLocalDateTime(moment()), settingTime: true, });
|
||||
}
|
||||
|
||||
closeSetTime = () => {
|
||||
this.setState({ settingTime: false });
|
||||
}
|
||||
|
||||
createAdjustedTime = (): Time => {
|
||||
const currentLocalTime = moment(this.props.data.time_local);
|
||||
const newLocalTime = moment(this.state.localTime);
|
||||
newLocalTime.subtract(currentLocalTime.utcOffset())
|
||||
newLocalTime.milliseconds(0);
|
||||
newLocalTime.utc();
|
||||
return {
|
||||
time_utc: newLocalTime.format()
|
||||
}
|
||||
}
|
||||
|
||||
configureTime = () => {
|
||||
this.setState({ processing: true });
|
||||
redirectingAuthorizedFetch(TIME_ENDPOINT,
|
||||
{
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.createAdjustedTime()),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
this.props.enqueueSnackbar("Time set successfully", { variant: 'success' });
|
||||
this.setState({ processing: false, settingTime: false }, this.props.loadData);
|
||||
} else {
|
||||
throw Error("Error setting time, status code: " + response.status);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.props.enqueueSnackbar(error.message || "Problem setting the time", { variant: 'error' });
|
||||
this.setState({ processing: false, settingTime: false });
|
||||
});
|
||||
}
|
||||
|
||||
renderSetTimeDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
open={this.state.settingTime}
|
||||
onClose={this.closeSetTime}
|
||||
>
|
||||
<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={this.state.localTime}
|
||||
onChange={this.updateLocalTime}
|
||||
disabled={this.state.processing}
|
||||
variant="outlined"
|
||||
fullWidth
|
||||
InputLabelProps={{
|
||||
shrink: true,
|
||||
}}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="contained" onClick={this.closeSetTime} color="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button startIcon={<AccessTimeIcon />} variant="contained" onClick={this.configureTime} disabled={this.state.processing} color="primary" autoFocus>
|
||||
Set Time
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const { data, theme } = this.props
|
||||
const me = this.props.authenticatedContext.me;
|
||||
return (
|
||||
<Fragment>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<HighlightAvatar color={ntpStatusHighlight(data, theme)}>
|
||||
<UpdateIcon />
|
||||
</HighlightAvatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Status" secondary={ntpStatus(data)} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
{isNtpActive(data) && (
|
||||
<Fragment>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<DNSIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="NTP Server" secondary={data.server} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</Fragment>
|
||||
)}
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<AccessTimeIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Local Time" secondary={formatIsoDateTime(data.time_local)} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<SwapVerticalCircleIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="UTC Time" secondary={formatIsoDateTime(data.time_utc)} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<AvTimerIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Uptime" secondary={moment.duration(data.uptime, 'seconds').humanize()} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</List>
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1} padding={1}>
|
||||
<FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}>
|
||||
Refresh
|
||||
</FormButton>
|
||||
</Box>
|
||||
{me.admin && !isNtpActive(data) && (
|
||||
<Box flexWrap="none" padding={1} whiteSpace="nowrap">
|
||||
<Button onClick={this.openSetTime} variant="contained" color="primary" startIcon={<AccessTimeIcon />}>
|
||||
Set Time
|
||||
</Button>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{this.renderSetTimeDialog()}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default withAuthenticatedContext(withTheme(NTPStatusForm));
|
39
interface/src/ntp/NetworkTime.tsx
Normal file
39
interface/src/ntp/NetworkTime.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom'
|
||||
|
||||
import { Tabs, Tab } from '@material-ui/core';
|
||||
|
||||
import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication';
|
||||
import { MenuAppBar } from '../components';
|
||||
|
||||
import NTPStatusController from './NTPStatusController';
|
||||
import NTPSettingsController from './NTPSettingsController';
|
||||
|
||||
type NetworkTimeProps = AuthenticatedContextProps & RouteComponentProps;
|
||||
|
||||
class NetworkTime extends Component<NetworkTimeProps> {
|
||||
|
||||
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
|
||||
this.props.history.push(path);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { authenticatedContext } = this.props;
|
||||
return (
|
||||
<MenuAppBar sectionTitle="Network Time">
|
||||
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth">
|
||||
<Tab value="/ntp/status" label="NTP Status" />
|
||||
<Tab value="/ntp/settings" label="NTP Settings" disabled={!authenticatedContext.me.admin} />
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<AuthenticatedRoute exact path="/ntp/status" component={NTPStatusController} />
|
||||
<AuthenticatedRoute exact path="/ntp/settings" component={NTPSettingsController} />
|
||||
<Redirect to="/ntp/status" />
|
||||
</Switch>
|
||||
</MenuAppBar>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withAuthenticatedContext(NetworkTime)
|
479
interface/src/ntp/TZ.tsx
Normal file
479
interface/src/ntp/TZ.tsx
Normal file
@ -0,0 +1,479 @@
|
||||
import React from 'react';
|
||||
import MenuItem from '@material-ui/core/MenuItem';
|
||||
|
||||
type TimeZones = {
|
||||
[name: string]: string
|
||||
};
|
||||
|
||||
export const TIME_ZONES: TimeZones = {
|
||||
"Africa/Abidjan": "GMT0",
|
||||
"Africa/Accra": "GMT0",
|
||||
"Africa/Addis_Ababa": "EAT-3",
|
||||
"Africa/Algiers": "CET-1",
|
||||
"Africa/Asmara": "EAT-3",
|
||||
"Africa/Bamako": "GMT0",
|
||||
"Africa/Bangui": "WAT-1",
|
||||
"Africa/Banjul": "GMT0",
|
||||
"Africa/Bissau": "GMT0",
|
||||
"Africa/Blantyre": "CAT-2",
|
||||
"Africa/Brazzaville": "WAT-1",
|
||||
"Africa/Bujumbura": "CAT-2",
|
||||
"Africa/Cairo": "EET-2",
|
||||
"Africa/Casablanca": "UNK-1",
|
||||
"Africa/Ceuta": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Africa/Conakry": "GMT0",
|
||||
"Africa/Dakar": "GMT0",
|
||||
"Africa/Dar_es_Salaam": "EAT-3",
|
||||
"Africa/Djibouti": "EAT-3",
|
||||
"Africa/Douala": "WAT-1",
|
||||
"Africa/El_Aaiun": "UNK-1",
|
||||
"Africa/Freetown": "GMT0",
|
||||
"Africa/Gaborone": "CAT-2",
|
||||
"Africa/Harare": "CAT-2",
|
||||
"Africa/Johannesburg": "SAST-2",
|
||||
"Africa/Juba": "EAT-3",
|
||||
"Africa/Kampala": "EAT-3",
|
||||
"Africa/Khartoum": "CAT-2",
|
||||
"Africa/Kigali": "CAT-2",
|
||||
"Africa/Kinshasa": "WAT-1",
|
||||
"Africa/Lagos": "WAT-1",
|
||||
"Africa/Libreville": "WAT-1",
|
||||
"Africa/Lome": "GMT0",
|
||||
"Africa/Luanda": "WAT-1",
|
||||
"Africa/Lubumbashi": "CAT-2",
|
||||
"Africa/Lusaka": "CAT-2",
|
||||
"Africa/Malabo": "WAT-1",
|
||||
"Africa/Maputo": "CAT-2",
|
||||
"Africa/Maseru": "SAST-2",
|
||||
"Africa/Mbabane": "SAST-2",
|
||||
"Africa/Mogadishu": "EAT-3",
|
||||
"Africa/Monrovia": "GMT0",
|
||||
"Africa/Nairobi": "EAT-3",
|
||||
"Africa/Ndjamena": "WAT-1",
|
||||
"Africa/Niamey": "WAT-1",
|
||||
"Africa/Nouakchott": "GMT0",
|
||||
"Africa/Ouagadougou": "GMT0",
|
||||
"Africa/Porto-Novo": "WAT-1",
|
||||
"Africa/Sao_Tome": "GMT0",
|
||||
"Africa/Tripoli": "EET-2",
|
||||
"Africa/Tunis": "CET-1",
|
||||
"Africa/Windhoek": "CAT-2",
|
||||
"America/Adak": "HST10HDT,M3.2.0,M11.1.0",
|
||||
"America/Anchorage": "AKST9AKDT,M3.2.0,M11.1.0",
|
||||
"America/Anguilla": "AST4",
|
||||
"America/Antigua": "AST4",
|
||||
"America/Araguaina": "UNK3",
|
||||
"America/Argentina/Buenos_Aires": "UNK3",
|
||||
"America/Argentina/Catamarca": "UNK3",
|
||||
"America/Argentina/Cordoba": "UNK3",
|
||||
"America/Argentina/Jujuy": "UNK3",
|
||||
"America/Argentina/La_Rioja": "UNK3",
|
||||
"America/Argentina/Mendoza": "UNK3",
|
||||
"America/Argentina/Rio_Gallegos": "UNK3",
|
||||
"America/Argentina/Salta": "UNK3",
|
||||
"America/Argentina/San_Juan": "UNK3",
|
||||
"America/Argentina/San_Luis": "UNK3",
|
||||
"America/Argentina/Tucuman": "UNK3",
|
||||
"America/Argentina/Ushuaia": "UNK3",
|
||||
"America/Aruba": "AST4",
|
||||
"America/Asuncion": "UNK4UNK,M10.1.0/0,M3.4.0/0",
|
||||
"America/Atikokan": "EST5",
|
||||
"America/Bahia": "UNK3",
|
||||
"America/Bahia_Banderas": "CST6CDT,M4.1.0,M10.5.0",
|
||||
"America/Barbados": "AST4",
|
||||
"America/Belem": "UNK3",
|
||||
"America/Belize": "CST6",
|
||||
"America/Blanc-Sablon": "AST4",
|
||||
"America/Boa_Vista": "UNK4",
|
||||
"America/Bogota": "UNK5",
|
||||
"America/Boise": "MST7MDT,M3.2.0,M11.1.0",
|
||||
"America/Cambridge_Bay": "MST7MDT,M3.2.0,M11.1.0",
|
||||
"America/Campo_Grande": "UNK4",
|
||||
"America/Cancun": "EST5",
|
||||
"America/Caracas": "UNK4",
|
||||
"America/Cayenne": "UNK3",
|
||||
"America/Cayman": "EST5",
|
||||
"America/Chicago": "CST6CDT,M3.2.0,M11.1.0",
|
||||
"America/Chihuahua": "MST7MDT,M4.1.0,M10.5.0",
|
||||
"America/Costa_Rica": "CST6",
|
||||
"America/Creston": "MST7",
|
||||
"America/Cuiaba": "UNK4",
|
||||
"America/Curacao": "AST4",
|
||||
"America/Danmarkshavn": "GMT0",
|
||||
"America/Dawson": "MST7",
|
||||
"America/Dawson_Creek": "MST7",
|
||||
"America/Denver": "MST7MDT,M3.2.0,M11.1.0",
|
||||
"America/Detroit": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Dominica": "AST4",
|
||||
"America/Edmonton": "MST7MDT,M3.2.0,M11.1.0",
|
||||
"America/Eirunepe": "UNK5",
|
||||
"America/El_Salvador": "CST6",
|
||||
"America/Fort_Nelson": "MST7",
|
||||
"America/Fortaleza": "UNK3",
|
||||
"America/Glace_Bay": "AST4ADT,M3.2.0,M11.1.0",
|
||||
"America/Godthab": "UNK3UNK,M3.5.0/-2,M10.5.0/-1",
|
||||
"America/Goose_Bay": "AST4ADT,M3.2.0,M11.1.0",
|
||||
"America/Grand_Turk": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Grenada": "AST4",
|
||||
"America/Guadeloupe": "AST4",
|
||||
"America/Guatemala": "CST6",
|
||||
"America/Guayaquil": "UNK5",
|
||||
"America/Guyana": "UNK4",
|
||||
"America/Halifax": "AST4ADT,M3.2.0,M11.1.0",
|
||||
"America/Havana": "CST5CDT,M3.2.0/0,M11.1.0/1",
|
||||
"America/Hermosillo": "MST7",
|
||||
"America/Indiana/Indianapolis": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Indiana/Knox": "CST6CDT,M3.2.0,M11.1.0",
|
||||
"America/Indiana/Marengo": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Indiana/Petersburg": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Indiana/Tell_City": "CST6CDT,M3.2.0,M11.1.0",
|
||||
"America/Indiana/Vevay": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Indiana/Vincennes": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Indiana/Winamac": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Inuvik": "MST7MDT,M3.2.0,M11.1.0",
|
||||
"America/Iqaluit": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Jamaica": "EST5",
|
||||
"America/Juneau": "AKST9AKDT,M3.2.0,M11.1.0",
|
||||
"America/Kentucky/Louisville": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Kentucky/Monticello": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Kralendijk": "AST4",
|
||||
"America/La_Paz": "UNK4",
|
||||
"America/Lima": "UNK5",
|
||||
"America/Los_Angeles": "PST8PDT,M3.2.0,M11.1.0",
|
||||
"America/Lower_Princes": "AST4",
|
||||
"America/Maceio": "UNK3",
|
||||
"America/Managua": "CST6",
|
||||
"America/Manaus": "UNK4",
|
||||
"America/Marigot": "AST4",
|
||||
"America/Martinique": "AST4",
|
||||
"America/Matamoros": "CST6CDT,M3.2.0,M11.1.0",
|
||||
"America/Mazatlan": "MST7MDT,M4.1.0,M10.5.0",
|
||||
"America/Menominee": "CST6CDT,M3.2.0,M11.1.0",
|
||||
"America/Merida": "CST6CDT,M4.1.0,M10.5.0",
|
||||
"America/Metlakatla": "AKST9AKDT,M3.2.0,M11.1.0",
|
||||
"America/Mexico_City": "CST6CDT,M4.1.0,M10.5.0",
|
||||
"America/Miquelon": "UNK3UNK,M3.2.0,M11.1.0",
|
||||
"America/Moncton": "AST4ADT,M3.2.0,M11.1.0",
|
||||
"America/Monterrey": "CST6CDT,M4.1.0,M10.5.0",
|
||||
"America/Montevideo": "UNK3",
|
||||
"America/Montreal": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Montserrat": "AST4",
|
||||
"America/Nassau": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/New_York": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Nipigon": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Nome": "AKST9AKDT,M3.2.0,M11.1.0",
|
||||
"America/Noronha": "UNK2",
|
||||
"America/North_Dakota/Beulah": "CST6CDT,M3.2.0,M11.1.0",
|
||||
"America/North_Dakota/Center": "CST6CDT,M3.2.0,M11.1.0",
|
||||
"America/North_Dakota/New_Salem": "CST6CDT,M3.2.0,M11.1.0",
|
||||
"America/Ojinaga": "MST7MDT,M3.2.0,M11.1.0",
|
||||
"America/Panama": "EST5",
|
||||
"America/Pangnirtung": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Paramaribo": "UNK3",
|
||||
"America/Phoenix": "MST7",
|
||||
"America/Port-au-Prince": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Port_of_Spain": "AST4",
|
||||
"America/Porto_Velho": "UNK4",
|
||||
"America/Puerto_Rico": "AST4",
|
||||
"America/Punta_Arenas": "UNK3",
|
||||
"America/Rainy_River": "CST6CDT,M3.2.0,M11.1.0",
|
||||
"America/Rankin_Inlet": "CST6CDT,M3.2.0,M11.1.0",
|
||||
"America/Recife": "UNK3",
|
||||
"America/Regina": "CST6",
|
||||
"America/Resolute": "CST6CDT,M3.2.0,M11.1.0",
|
||||
"America/Rio_Branco": "UNK5",
|
||||
"America/Santarem": "UNK3",
|
||||
"America/Santiago": "UNK4UNK,M9.1.6/24,M4.1.6/24",
|
||||
"America/Santo_Domingo": "AST4",
|
||||
"America/Sao_Paulo": "UNK3",
|
||||
"America/Scoresbysund": "UNK1UNK,M3.5.0/0,M10.5.0/1",
|
||||
"America/Sitka": "AKST9AKDT,M3.2.0,M11.1.0",
|
||||
"America/St_Barthelemy": "AST4",
|
||||
"America/St_Johns": "NST3:30NDT,M3.2.0,M11.1.0",
|
||||
"America/St_Kitts": "AST4",
|
||||
"America/St_Lucia": "AST4",
|
||||
"America/St_Thomas": "AST4",
|
||||
"America/St_Vincent": "AST4",
|
||||
"America/Swift_Current": "CST6",
|
||||
"America/Tegucigalpa": "CST6",
|
||||
"America/Thule": "AST4ADT,M3.2.0,M11.1.0",
|
||||
"America/Thunder_Bay": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Tijuana": "PST8PDT,M3.2.0,M11.1.0",
|
||||
"America/Toronto": "EST5EDT,M3.2.0,M11.1.0",
|
||||
"America/Tortola": "AST4",
|
||||
"America/Vancouver": "PST8PDT,M3.2.0,M11.1.0",
|
||||
"America/Whitehorse": "MST7",
|
||||
"America/Winnipeg": "CST6CDT,M3.2.0,M11.1.0",
|
||||
"America/Yakutat": "AKST9AKDT,M3.2.0,M11.1.0",
|
||||
"America/Yellowknife": "MST7MDT,M3.2.0,M11.1.0",
|
||||
"Antarctica/Casey": "UNK-8",
|
||||
"Antarctica/Davis": "UNK-7",
|
||||
"Antarctica/DumontDUrville": "UNK-10",
|
||||
"Antarctica/Macquarie": "UNK-11",
|
||||
"Antarctica/Mawson": "UNK-5",
|
||||
"Antarctica/McMurdo": "NZST-12NZDT,M9.5.0,M4.1.0/3",
|
||||
"Antarctica/Palmer": "UNK3",
|
||||
"Antarctica/Rothera": "UNK3",
|
||||
"Antarctica/Syowa": "UNK-3",
|
||||
"Antarctica/Troll": "UNK0UNK-2,M3.5.0/1,M10.5.0/3",
|
||||
"Antarctica/Vostok": "UNK-6",
|
||||
"Arctic/Longyearbyen": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Asia/Aden": "UNK-3",
|
||||
"Asia/Almaty": "UNK-6",
|
||||
"Asia/Amman": "EET-2EEST,M3.5.4/24,M10.5.5/1",
|
||||
"Asia/Anadyr": "UNK-12",
|
||||
"Asia/Aqtau": "UNK-5",
|
||||
"Asia/Aqtobe": "UNK-5",
|
||||
"Asia/Ashgabat": "UNK-5",
|
||||
"Asia/Atyrau": "UNK-5",
|
||||
"Asia/Baghdad": "UNK-3",
|
||||
"Asia/Bahrain": "UNK-3",
|
||||
"Asia/Baku": "UNK-4",
|
||||
"Asia/Bangkok": "UNK-7",
|
||||
"Asia/Barnaul": "UNK-7",
|
||||
"Asia/Beirut": "EET-2EEST,M3.5.0/0,M10.5.0/0",
|
||||
"Asia/Bishkek": "UNK-6",
|
||||
"Asia/Brunei": "UNK-8",
|
||||
"Asia/Chita": "UNK-9",
|
||||
"Asia/Choibalsan": "UNK-8",
|
||||
"Asia/Colombo": "UNK-5:30",
|
||||
"Asia/Damascus": "EET-2EEST,M3.5.5/0,M10.5.5/0",
|
||||
"Asia/Dhaka": "UNK-6",
|
||||
"Asia/Dili": "UNK-9",
|
||||
"Asia/Dubai": "UNK-4",
|
||||
"Asia/Dushanbe": "UNK-5",
|
||||
"Asia/Famagusta": "EET-2EEST,M3.5.0/3,M10.5.0/4",
|
||||
"Asia/Gaza": "EET-2EEST,M3.5.5/0,M10.5.6/1",
|
||||
"Asia/Hebron": "EET-2EEST,M3.5.5/0,M10.5.6/1",
|
||||
"Asia/Ho_Chi_Minh": "UNK-7",
|
||||
"Asia/Hong_Kong": "HKT-8",
|
||||
"Asia/Hovd": "UNK-7",
|
||||
"Asia/Irkutsk": "UNK-8",
|
||||
"Asia/Jakarta": "WIB-7",
|
||||
"Asia/Jayapura": "WIT-9",
|
||||
"Asia/Jerusalem": "IST-2IDT,M3.4.4/26,M10.5.0",
|
||||
"Asia/Kabul": "UNK-4:30",
|
||||
"Asia/Kamchatka": "UNK-12",
|
||||
"Asia/Karachi": "PKT-5",
|
||||
"Asia/Kathmandu": "UNK-5:45",
|
||||
"Asia/Khandyga": "UNK-9",
|
||||
"Asia/Kolkata": "IST-5:30",
|
||||
"Asia/Krasnoyarsk": "UNK-7",
|
||||
"Asia/Kuala_Lumpur": "UNK-8",
|
||||
"Asia/Kuching": "UNK-8",
|
||||
"Asia/Kuwait": "UNK-3",
|
||||
"Asia/Macau": "CST-8",
|
||||
"Asia/Magadan": "UNK-11",
|
||||
"Asia/Makassar": "WITA-8",
|
||||
"Asia/Manila": "PST-8",
|
||||
"Asia/Muscat": "UNK-4",
|
||||
"Asia/Nicosia": "EET-2EEST,M3.5.0/3,M10.5.0/4",
|
||||
"Asia/Novokuznetsk": "UNK-7",
|
||||
"Asia/Novosibirsk": "UNK-7",
|
||||
"Asia/Omsk": "UNK-6",
|
||||
"Asia/Oral": "UNK-5",
|
||||
"Asia/Phnom_Penh": "UNK-7",
|
||||
"Asia/Pontianak": "WIB-7",
|
||||
"Asia/Pyongyang": "KST-9",
|
||||
"Asia/Qatar": "UNK-3",
|
||||
"Asia/Qyzylorda": "UNK-5",
|
||||
"Asia/Riyadh": "UNK-3",
|
||||
"Asia/Sakhalin": "UNK-11",
|
||||
"Asia/Samarkand": "UNK-5",
|
||||
"Asia/Seoul": "KST-9",
|
||||
"Asia/Shanghai": "CST-8",
|
||||
"Asia/Singapore": "UNK-8",
|
||||
"Asia/Srednekolymsk": "UNK-11",
|
||||
"Asia/Taipei": "CST-8",
|
||||
"Asia/Tashkent": "UNK-5",
|
||||
"Asia/Tbilisi": "UNK-4",
|
||||
"Asia/Tehran": "UNK-3:30UNK,J79/24,J263/24",
|
||||
"Asia/Thimphu": "UNK-6",
|
||||
"Asia/Tokyo": "JST-9",
|
||||
"Asia/Tomsk": "UNK-7",
|
||||
"Asia/Ulaanbaatar": "UNK-8",
|
||||
"Asia/Urumqi": "UNK-6",
|
||||
"Asia/Ust-Nera": "UNK-10",
|
||||
"Asia/Vientiane": "UNK-7",
|
||||
"Asia/Vladivostok": "UNK-10",
|
||||
"Asia/Yakutsk": "UNK-9",
|
||||
"Asia/Yangon": "UNK-6:30",
|
||||
"Asia/Yekaterinburg": "UNK-5",
|
||||
"Asia/Yerevan": "UNK-4",
|
||||
"Atlantic/Azores": "UNK1UNK,M3.5.0/0,M10.5.0/1",
|
||||
"Atlantic/Bermuda": "AST4ADT,M3.2.0,M11.1.0",
|
||||
"Atlantic/Canary": "WET0WEST,M3.5.0/1,M10.5.0",
|
||||
"Atlantic/Cape_Verde": "UNK1",
|
||||
"Atlantic/Faroe": "WET0WEST,M3.5.0/1,M10.5.0",
|
||||
"Atlantic/Madeira": "WET0WEST,M3.5.0/1,M10.5.0",
|
||||
"Atlantic/Reykjavik": "GMT0",
|
||||
"Atlantic/South_Georgia": "UNK2",
|
||||
"Atlantic/St_Helena": "GMT0",
|
||||
"Atlantic/Stanley": "UNK3",
|
||||
"Australia/Adelaide": "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
|
||||
"Australia/Brisbane": "AEST-10",
|
||||
"Australia/Broken_Hill": "ACST-9:30ACDT,M10.1.0,M4.1.0/3",
|
||||
"Australia/Currie": "AEST-10AEDT,M10.1.0,M4.1.0/3",
|
||||
"Australia/Darwin": "ACST-9:30",
|
||||
"Australia/Eucla": "UNK-8:45",
|
||||
"Australia/Hobart": "AEST-10AEDT,M10.1.0,M4.1.0/3",
|
||||
"Australia/Lindeman": "AEST-10",
|
||||
"Australia/Lord_Howe": "UNK-10:30UNK-11,M10.1.0,M4.1.0",
|
||||
"Australia/Melbourne": "AEST-10AEDT,M10.1.0,M4.1.0/3",
|
||||
"Australia/Perth": "AWST-8",
|
||||
"Australia/Sydney": "AEST-10AEDT,M10.1.0,M4.1.0/3",
|
||||
"Etc/GMT": "GMT0",
|
||||
"Etc/GMT+0": "GMT0",
|
||||
"Etc/GMT+1": "UNK1",
|
||||
"Etc/GMT+10": "UNK10",
|
||||
"Etc/GMT+11": "UNK11",
|
||||
"Etc/GMT+12": "UNK12",
|
||||
"Etc/GMT+2": "UNK2",
|
||||
"Etc/GMT+3": "UNK3",
|
||||
"Etc/GMT+4": "UNK4",
|
||||
"Etc/GMT+5": "UNK5",
|
||||
"Etc/GMT+6": "UNK6",
|
||||
"Etc/GMT+7": "UNK7",
|
||||
"Etc/GMT+8": "UNK8",
|
||||
"Etc/GMT+9": "UNK9",
|
||||
"Etc/GMT-0": "GMT0",
|
||||
"Etc/GMT-1": "UNK-1",
|
||||
"Etc/GMT-10": "UNK-10",
|
||||
"Etc/GMT-11": "UNK-11",
|
||||
"Etc/GMT-12": "UNK-12",
|
||||
"Etc/GMT-13": "UNK-13",
|
||||
"Etc/GMT-14": "UNK-14",
|
||||
"Etc/GMT-2": "UNK-2",
|
||||
"Etc/GMT-3": "UNK-3",
|
||||
"Etc/GMT-4": "UNK-4",
|
||||
"Etc/GMT-5": "UNK-5",
|
||||
"Etc/GMT-6": "UNK-6",
|
||||
"Etc/GMT-7": "UNK-7",
|
||||
"Etc/GMT-8": "UNK-8",
|
||||
"Etc/GMT-9": "UNK-9",
|
||||
"Etc/GMT0": "GMT0",
|
||||
"Etc/Greenwich": "GMT0",
|
||||
"Etc/UCT": "UTC0",
|
||||
"Etc/UTC": "UTC0",
|
||||
"Etc/Universal": "UTC0",
|
||||
"Etc/Zulu": "UTC0",
|
||||
"Europe/Amsterdam": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Andorra": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Astrakhan": "UNK-4",
|
||||
"Europe/Athens": "EET-2EEST,M3.5.0/3,M10.5.0/4",
|
||||
"Europe/Belgrade": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Berlin": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Bratislava": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Brussels": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Bucharest": "EET-2EEST,M3.5.0/3,M10.5.0/4",
|
||||
"Europe/Budapest": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Busingen": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Chisinau": "EET-2EEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Copenhagen": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Dublin": "IST-1GMT0,M10.5.0,M3.5.0/1",
|
||||
"Europe/Gibraltar": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Guernsey": "GMT0BST,M3.5.0/1,M10.5.0",
|
||||
"Europe/Helsinki": "EET-2EEST,M3.5.0/3,M10.5.0/4",
|
||||
"Europe/Isle_of_Man": "GMT0BST,M3.5.0/1,M10.5.0",
|
||||
"Europe/Istanbul": "UNK-3",
|
||||
"Europe/Jersey": "GMT0BST,M3.5.0/1,M10.5.0",
|
||||
"Europe/Kaliningrad": "EET-2",
|
||||
"Europe/Kiev": "EET-2EEST,M3.5.0/3,M10.5.0/4",
|
||||
"Europe/Kirov": "UNK-3",
|
||||
"Europe/Lisbon": "WET0WEST,M3.5.0/1,M10.5.0",
|
||||
"Europe/Ljubljana": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/London": "GMT0BST,M3.5.0/1,M10.5.0",
|
||||
"Europe/Luxembourg": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Madrid": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Malta": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Mariehamn": "EET-2EEST,M3.5.0/3,M10.5.0/4",
|
||||
"Europe/Minsk": "UNK-3",
|
||||
"Europe/Monaco": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Moscow": "MSK-3",
|
||||
"Europe/Oslo": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Paris": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Podgorica": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Prague": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Riga": "EET-2EEST,M3.5.0/3,M10.5.0/4",
|
||||
"Europe/Rome": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Samara": "UNK-4",
|
||||
"Europe/San_Marino": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Sarajevo": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Saratov": "UNK-4",
|
||||
"Europe/Simferopol": "MSK-3",
|
||||
"Europe/Skopje": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Sofia": "EET-2EEST,M3.5.0/3,M10.5.0/4",
|
||||
"Europe/Stockholm": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Tallinn": "EET-2EEST,M3.5.0/3,M10.5.0/4",
|
||||
"Europe/Tirane": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Ulyanovsk": "UNK-4",
|
||||
"Europe/Uzhgorod": "EET-2EEST,M3.5.0/3,M10.5.0/4",
|
||||
"Europe/Vaduz": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Vatican": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Vienna": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Vilnius": "EET-2EEST,M3.5.0/3,M10.5.0/4",
|
||||
"Europe/Volgograd": "UNK-4",
|
||||
"Europe/Warsaw": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Zagreb": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Europe/Zaporozhye": "EET-2EEST,M3.5.0/3,M10.5.0/4",
|
||||
"Europe/Zurich": "CET-1CEST,M3.5.0,M10.5.0/3",
|
||||
"Indian/Antananarivo": "EAT-3",
|
||||
"Indian/Chagos": "UNK-6",
|
||||
"Indian/Christmas": "UNK-7",
|
||||
"Indian/Cocos": "UNK-6:30",
|
||||
"Indian/Comoro": "EAT-3",
|
||||
"Indian/Kerguelen": "UNK-5",
|
||||
"Indian/Mahe": "UNK-4",
|
||||
"Indian/Maldives": "UNK-5",
|
||||
"Indian/Mauritius": "UNK-4",
|
||||
"Indian/Mayotte": "EAT-3",
|
||||
"Indian/Reunion": "UNK-4",
|
||||
"Pacific/Apia": "UNK-13UNK,M9.5.0/3,M4.1.0/4",
|
||||
"Pacific/Auckland": "NZST-12NZDT,M9.5.0,M4.1.0/3",
|
||||
"Pacific/Bougainville": "UNK-11",
|
||||
"Pacific/Chatham": "UNK-12:45UNK,M9.5.0/2:45,M4.1.0/3:45",
|
||||
"Pacific/Chuuk": "UNK-10",
|
||||
"Pacific/Easter": "UNK6UNK,M9.1.6/22,M4.1.6/22",
|
||||
"Pacific/Efate": "UNK-11",
|
||||
"Pacific/Enderbury": "UNK-13",
|
||||
"Pacific/Fakaofo": "UNK-13",
|
||||
"Pacific/Fiji": "UNK-12UNK,M11.2.0,M1.2.3/99",
|
||||
"Pacific/Funafuti": "UNK-12",
|
||||
"Pacific/Galapagos": "UNK6",
|
||||
"Pacific/Gambier": "UNK9",
|
||||
"Pacific/Guadalcanal": "UNK-11",
|
||||
"Pacific/Guam": "ChST-10",
|
||||
"Pacific/Honolulu": "HST10",
|
||||
"Pacific/Kiritimati": "UNK-14",
|
||||
"Pacific/Kosrae": "UNK-11",
|
||||
"Pacific/Kwajalein": "UNK-12",
|
||||
"Pacific/Majuro": "UNK-12",
|
||||
"Pacific/Marquesas": "UNK9:30",
|
||||
"Pacific/Midway": "SST11",
|
||||
"Pacific/Nauru": "UNK-12",
|
||||
"Pacific/Niue": "UNK11",
|
||||
"Pacific/Norfolk": "UNK-11UNK,M10.1.0,M4.1.0/3",
|
||||
"Pacific/Noumea": "UNK-11",
|
||||
"Pacific/Pago_Pago": "SST11",
|
||||
"Pacific/Palau": "UNK-9",
|
||||
"Pacific/Pitcairn": "UNK8",
|
||||
"Pacific/Pohnpei": "UNK-11",
|
||||
"Pacific/Port_Moresby": "UNK-10",
|
||||
"Pacific/Rarotonga": "UNK10",
|
||||
"Pacific/Saipan": "ChST-10",
|
||||
"Pacific/Tahiti": "UNK10",
|
||||
"Pacific/Tarawa": "UNK-12",
|
||||
"Pacific/Tongatapu": "UNK-13",
|
||||
"Pacific/Wake": "UNK-12",
|
||||
"Pacific/Wallis": "UNK-12"
|
||||
}
|
||||
|
||||
export function selectedTimeZone(label: string, format: string) {
|
||||
return TIME_ZONES[label] === format ? label : undefined;
|
||||
}
|
||||
|
||||
export function timeZoneSelectItems() {
|
||||
return Object.keys(TIME_ZONES).map(label => (
|
||||
<MenuItem key={label} value={label}>{label}</MenuItem>
|
||||
));
|
||||
}
|
5
interface/src/ntp/TimeFormat.ts
Normal file
5
interface/src/ntp/TimeFormat.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import moment, { Moment } from 'moment';
|
||||
|
||||
export const formatIsoDateTime = (isoDateString: string) => moment.parseZone(isoDateString).format('ll @ HH:mm:ss');
|
||||
|
||||
export const formatLocalDateTime = (moment: Moment) => moment.format('YYYY-MM-DDTHH:mm');
|
23
interface/src/ntp/types.ts
Normal file
23
interface/src/ntp/types.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export enum NTPSyncStatus {
|
||||
NTP_INACTIVE = 0,
|
||||
NTP_ACTIVE = 1
|
||||
}
|
||||
|
||||
export interface NTPStatus {
|
||||
status: NTPSyncStatus;
|
||||
time_utc: string;
|
||||
time_local: string;
|
||||
server: string;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export interface NTPSettings {
|
||||
enabled: boolean;
|
||||
server: string;
|
||||
tz_label: string;
|
||||
tz_format: string;
|
||||
}
|
||||
|
||||
export interface Time {
|
||||
time_utc: string;
|
||||
}
|
120
interface/src/project/GeneralInformation.tsx
Normal file
120
interface/src/project/GeneralInformation.tsx
Normal file
@ -0,0 +1,120 @@
|
||||
import React, {Component} from 'react';
|
||||
import {Box, List, ListItem, ListItemText} from '@material-ui/core';
|
||||
import {
|
||||
FormButton,
|
||||
restController,
|
||||
RestControllerProps,
|
||||
RestFormLoader,
|
||||
SectionContent
|
||||
} from '../components';
|
||||
import {ENDPOINT_ROOT} from "../api";
|
||||
import {GeneralInformaitonState} from "./types";
|
||||
import RefreshIcon from "@material-ui/icons/Refresh";
|
||||
|
||||
// define api endpoint
|
||||
export const GENERALINFORMATION_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "generalinfo";
|
||||
|
||||
type GeneralInformationRestControllerProps = RestControllerProps<GeneralInformaitonState>;
|
||||
|
||||
class GeneralInformation extends Component<GeneralInformationRestControllerProps> {
|
||||
intervalhandler: number | undefined;
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
|
||||
// this.intervalhandler = window.setInterval(() => {
|
||||
// this.props.loadData();
|
||||
// console.log("refreshing data");
|
||||
// console.log(this.props.data)
|
||||
// }, 10000);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.intervalhandler);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title='Information' titleGutter>
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={props => (
|
||||
<>
|
||||
<List>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Chip läuft seit:"
|
||||
secondary={this.stringifyTime(props.data.runtime)}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Zuletzt zu wenig Wasser:"
|
||||
secondary={props.data.lastWaterOutage !== 0 ? "vor " + this.stringifyTime(props.data.lastWaterOutage) : "noch nie!"}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Letzer Pumpenzyklus"
|
||||
secondary={props.data.lastpumptime !== 0 ? "vor " + this.stringifyTime(props.data.lastpumptime) : "noch nie!"}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Letze Pumpdauer:"
|
||||
secondary={props.data.lastPumpDuration !== 0 ? this.stringifyTime(props.data.lastPumpDuration) : "-"}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="Temperatur/Luftfeuchtigkeit:"
|
||||
secondary={(props.data.temp !== -1 ? props.data.temp + "C" : "Auslesefehler!") + " / " + (props.data.hum !== -1 ? props.data.hum + "%" : "Auslesefehler!")}
|
||||
/>
|
||||
</ListItem>
|
||||
<ListItem>
|
||||
<ListItemText
|
||||
primary="WasserSensor / DruckSensor"
|
||||
secondary={(props.data.watersensor ? "EIN" : "AUS!") + " / " + (props.data.pressuresensor ? "EIN" : "AUS")}
|
||||
/>
|
||||
</ListItem>
|
||||
</List>
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1} padding={1}>
|
||||
<FormButton startIcon={<RefreshIcon/>} variant="contained" color="secondary"
|
||||
onClick={this.props.loadData}>
|
||||
Refresh
|
||||
</FormButton>
|
||||
</Box>
|
||||
<Box flexWrap="none" padding={1} whiteSpace="nowrap">
|
||||
Version: {props.data.version}
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* stringify seconds to a pretty format
|
||||
* @param sec number of seconds
|
||||
*/
|
||||
stringifyTime(sec: number): string {
|
||||
if (sec >= 86400) {
|
||||
// display days
|
||||
return (Math.trunc(sec / 86400) + "d " + Math.trunc((sec % 86400) / 3600) + "h " + Math.trunc((sec % 3600) / 60) + "min " + sec % 60 + "sec");
|
||||
} else if (sec >= 3600) {
|
||||
// display hours
|
||||
return (Math.trunc(sec / 3600) + "h " + Math.trunc((sec % 3600) / 60) + "min " + sec % 60 + "sec");
|
||||
} else if (sec >= 60) {
|
||||
// only seconds and minutes
|
||||
return (Math.trunc(sec / 60) + "min " + sec % 60 + "sec");
|
||||
} else {
|
||||
// only seconds
|
||||
return (sec + "sec");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default restController(GENERALINFORMATION_SETTINGS_ENDPOINT, GeneralInformation);
|
27
interface/src/project/ProjectMenu.tsx
Normal file
27
interface/src/project/ProjectMenu.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
|
||||
|
||||
import {List, ListItem, ListItemIcon, ListItemText} from '@material-ui/core';
|
||||
import SettingsRemoteIcon from '@material-ui/icons/SettingsRemote';
|
||||
|
||||
import { PROJECT_PATH } from '../api';
|
||||
|
||||
class ProjectMenu extends Component<RouteComponentProps> {
|
||||
|
||||
render() {
|
||||
const path = this.props.match.url;
|
||||
return (
|
||||
<List>
|
||||
<ListItem to={`/${PROJECT_PATH}/pumpe/`} selected={path.startsWith(`/${PROJECT_PATH}/pumpe/`)} button component={Link}>
|
||||
<ListItemIcon>
|
||||
<SettingsRemoteIcon />
|
||||
</ListItemIcon>
|
||||
<ListItemText primary="Pumpensteuerung" />
|
||||
</ListItem>
|
||||
</List>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withRouter(ProjectMenu);
|
33
interface/src/project/ProjectRouting.tsx
Normal file
33
interface/src/project/ProjectRouting.tsx
Normal file
@ -0,0 +1,33 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Redirect, Switch } from 'react-router';
|
||||
|
||||
import { PROJECT_PATH } from '../api';
|
||||
import { AuthenticatedRoute } from '../authentication';
|
||||
|
||||
import PumpControl from './PumpControl';
|
||||
|
||||
class ProjectRouting extends Component {
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Switch>
|
||||
{
|
||||
/*
|
||||
* Add your project page routing below.
|
||||
*/
|
||||
}
|
||||
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/pumpe/*`} component={PumpControl} />
|
||||
{
|
||||
/*
|
||||
* The redirect below caters for the default project route and redirecting invalid paths.
|
||||
* The "to" property must match one of the routes above for this to work correctly.
|
||||
*/
|
||||
}
|
||||
<Redirect to={`/${PROJECT_PATH}/pumpe/`} />
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default ProjectRouting;
|
37
interface/src/project/PumpControl.tsx
Normal file
37
interface/src/project/PumpControl.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom'
|
||||
|
||||
import { Tabs, Tab } from '@material-ui/core';
|
||||
|
||||
import { PROJECT_PATH } from '../api';
|
||||
import { MenuAppBar } from '../components';
|
||||
import { AuthenticatedRoute } from '../authentication';
|
||||
|
||||
import GeneralInformation from './GeneralInformation';
|
||||
import SettingsController from "./SettingsController";
|
||||
|
||||
class PumpControl extends Component<RouteComponentProps> {
|
||||
|
||||
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
|
||||
this.props.history.push(path);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<MenuAppBar sectionTitle="Wasserpumpensteuerung">
|
||||
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth">
|
||||
<Tab value={`/${PROJECT_PATH}/pumpe/information`} label="Information" />
|
||||
<Tab value={`/${PROJECT_PATH}/pumpe/settings`} label="Einstellungen" />
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/pumpe/information`} component={GeneralInformation} />
|
||||
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/pumpe/settings`} component={SettingsController} />
|
||||
<Redirect to={`/${PROJECT_PATH}/pumpe/information`} />
|
||||
</Switch>
|
||||
</MenuAppBar>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default PumpControl;
|
84
interface/src/project/SettingsController.tsx
Normal file
84
interface/src/project/SettingsController.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import React, {Component} from 'react';
|
||||
import {ValidatorForm} from 'react-material-ui-form-validator';
|
||||
|
||||
import {Typography, Box, TextField} from '@material-ui/core';
|
||||
import SaveIcon from '@material-ui/icons/Save';
|
||||
|
||||
import {ENDPOINT_ROOT} from '../api';
|
||||
import {
|
||||
restController,
|
||||
RestControllerProps,
|
||||
RestFormLoader,
|
||||
FormActions,
|
||||
FormButton,
|
||||
SectionContent,
|
||||
} from '../components';
|
||||
|
||||
import {SettingsState} from './types';
|
||||
|
||||
export const LIGHT_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "settings";
|
||||
|
||||
type LightStateRestControllerProps = RestControllerProps<SettingsState>;
|
||||
|
||||
class SettingsController extends Component<LightStateRestControllerProps> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title='Einstellungen' titleGutter>
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={props => {
|
||||
const {data, saveData, handleValueChange} = props;
|
||||
return (
|
||||
<ValidatorForm onSubmit={saveData}>
|
||||
<Box bgcolor="primary.main" color="primary.contrastText" p={2} mt={2} mb={2}>
|
||||
<Typography variant="body1">
|
||||
Die unten eingegebenen Werte werden nach klick des 'SAVE' Buttons übernommen und überleben einen Neustart.
|
||||
</Typography>
|
||||
</Box>
|
||||
<div>
|
||||
<TextField value={data.maxpumpduration} fullWidth={true} type='number'
|
||||
id="maxpumpduration" label="Maximale Pumpdauer [sec]"
|
||||
onChange={handleValueChange('maxpumpduration')}/>
|
||||
</div>
|
||||
<div>
|
||||
<TextField value={data.waterOutageWaitDuration} fullWidth={true} type='number'
|
||||
id="waterOutageWaitDuration" label="Wartezeit nach Wasserausfall [sec]"
|
||||
onChange={handleValueChange('waterOutageWaitDuration')}/>
|
||||
</div>
|
||||
<div>
|
||||
<TextField value={data.heatUp} fullWidth={true} type='number' id="heatUp"
|
||||
label="Obere Luftfeuchtigkeitsschwelle [%]"
|
||||
onChange={handleValueChange('heatUp')}/>
|
||||
</div>
|
||||
<div>
|
||||
<TextField value={data.heatLow} fullWidth={true} type='number' id="heatLow"
|
||||
label="Untere Luftfeuchtigkeitsschwelle [%]"
|
||||
onChange={handleValueChange('heatLow')}/>
|
||||
</div>
|
||||
<div>
|
||||
<TextField value={data.fanRuntime} fullWidth={true} type='number' id="fanRuntime"
|
||||
label="Nachlaufzeit Lüfter [sec]"
|
||||
onChange={handleValueChange('fanRuntime')}/>
|
||||
</div>
|
||||
|
||||
<FormActions>
|
||||
<FormButton startIcon={<SaveIcon/>} variant="contained" color="primary"
|
||||
type="submit">Save
|
||||
</FormButton>
|
||||
</FormActions>
|
||||
</ValidatorForm>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</SectionContent>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default restController(LIGHT_SETTINGS_ENDPOINT, SettingsController);
|
19
interface/src/project/types.ts
Normal file
19
interface/src/project/types.ts
Normal file
@ -0,0 +1,19 @@
|
||||
export interface SettingsState {
|
||||
maxpumpduration: number;
|
||||
waterOutageWaitDuration: number;
|
||||
heatUp: number;
|
||||
heatLow: number;
|
||||
fanRuntime: number;
|
||||
}
|
||||
|
||||
export interface GeneralInformaitonState {
|
||||
hum: number;
|
||||
temp: number;
|
||||
lastpumptime: number;
|
||||
lastWaterOutage: number;
|
||||
lastPumpDuration: number;
|
||||
runtime: number;
|
||||
watersensor: boolean;
|
||||
pressuresensor: boolean;
|
||||
version: string;
|
||||
}
|
1
interface/src/react-app-env.d.ts
vendored
Normal file
1
interface/src/react-app-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="react-scripts" />
|
30
interface/src/security/ManageUsersController.tsx
Normal file
30
interface/src/security/ManageUsersController.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
|
||||
import { SECURITY_SETTINGS_ENDPOINT } from '../api';
|
||||
|
||||
import ManageUsersForm from './ManageUsersForm';
|
||||
import { SecuritySettings } from './types';
|
||||
|
||||
type ManageUsersControllerProps = RestControllerProps<SecuritySettings>;
|
||||
|
||||
class ManageUsersController extends Component<ManageUsersControllerProps> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title="Manage Users" titleGutter>
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={formProps => <ManageUsersForm {...formProps} />}
|
||||
/>
|
||||
</SectionContent>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default restController(SECURITY_SETTINGS_ENDPOINT, ManageUsersController);
|
184
interface/src/security/ManageUsersForm.tsx
Normal file
184
interface/src/security/ManageUsersForm.tsx
Normal file
@ -0,0 +1,184 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { ValidatorForm } from 'react-material-ui-form-validator';
|
||||
|
||||
import { Table, TableBody, TableCell, TableHead, TableFooter, TableRow, withWidth, WithWidthProps, isWidthDown } from '@material-ui/core';
|
||||
import { Box, Button, Typography, } from '@material-ui/core';
|
||||
|
||||
import EditIcon from '@material-ui/icons/Edit';
|
||||
import DeleteIcon from '@material-ui/icons/Delete';
|
||||
import CloseIcon from '@material-ui/icons/Close';
|
||||
import CheckIcon from '@material-ui/icons/Check';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import SaveIcon from '@material-ui/icons/Save';
|
||||
import PersonAddIcon from '@material-ui/icons/PersonAdd';
|
||||
|
||||
import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication';
|
||||
import { RestFormProps, FormActions, FormButton, extractEventValue } from '../components';
|
||||
|
||||
import UserForm from './UserForm';
|
||||
import { SecuritySettings, User } from './types';
|
||||
|
||||
function compareUsers(a: User, b: User) {
|
||||
if (a.username < b.username) {
|
||||
return -1;
|
||||
}
|
||||
if (a.username > b.username) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
type ManageUsersFormProps = RestFormProps<SecuritySettings> & AuthenticatedContextProps & WithWidthProps;
|
||||
|
||||
type ManageUsersFormState = {
|
||||
creating: boolean;
|
||||
user?: User;
|
||||
}
|
||||
|
||||
class ManageUsersForm extends React.Component<ManageUsersFormProps, ManageUsersFormState> {
|
||||
|
||||
state: ManageUsersFormState = {
|
||||
creating: false
|
||||
};
|
||||
|
||||
createUser = () => {
|
||||
this.setState({
|
||||
creating: true,
|
||||
user: {
|
||||
username: "",
|
||||
password: "",
|
||||
admin: true
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
uniqueUsername = (username: string) => {
|
||||
return !this.props.data.users.find(u => u.username === username);
|
||||
}
|
||||
|
||||
noAdminConfigured = () => {
|
||||
return !this.props.data.users.find(u => u.admin);
|
||||
}
|
||||
|
||||
removeUser = (user: User) => {
|
||||
const { data } = this.props;
|
||||
const users = data.users.filter(u => u.username !== user.username);
|
||||
this.props.setData({ ...data, users });
|
||||
}
|
||||
|
||||
startEditingUser = (user: User) => {
|
||||
this.setState({
|
||||
creating: false,
|
||||
user
|
||||
});
|
||||
};
|
||||
|
||||
cancelEditingUser = () => {
|
||||
this.setState({
|
||||
user: undefined
|
||||
});
|
||||
}
|
||||
|
||||
doneEditingUser = () => {
|
||||
const { user } = this.state;
|
||||
if (user) {
|
||||
const { data } = this.props;
|
||||
const users = data.users.filter(u => u.username !== user.username);
|
||||
users.push(user);
|
||||
this.props.setData({ ...data, users });
|
||||
this.setState({
|
||||
user: undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
handleUserValueChange = (name: keyof User) => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
this.setState({ user: { ...this.state.user!, [name]: extractEventValue(event) } });
|
||||
};
|
||||
|
||||
onSubmit = () => {
|
||||
this.props.saveData();
|
||||
this.props.authenticatedContext.refresh();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { width, data } = this.props;
|
||||
const { user, creating } = this.state;
|
||||
return (
|
||||
<Fragment>
|
||||
<ValidatorForm onSubmit={this.onSubmit}>
|
||||
<Table size="small" padding={isWidthDown('xs', width!) ? "none" : "default"}>
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Username</TableCell>
|
||||
<TableCell align="center">Admin?</TableCell>
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{data.users.sort(compareUsers).map(user => (
|
||||
<TableRow key={user.username}>
|
||||
<TableCell component="th" scope="row">
|
||||
{user.username}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
{
|
||||
user.admin ? <CheckIcon /> : <CloseIcon />
|
||||
}
|
||||
</TableCell>
|
||||
<TableCell align="center">
|
||||
<IconButton size="small" aria-label="Delete" onClick={() => this.removeUser(user)}>
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<IconButton size="small" aria-label="Edit" onClick={() => this.startEditingUser(user)}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
<TableFooter >
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} />
|
||||
<TableCell align="center" padding="default">
|
||||
<Button startIcon={<PersonAddIcon />} variant="contained" color="secondary" onClick={this.createUser}>
|
||||
Add
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</TableFooter>
|
||||
</Table>
|
||||
{
|
||||
this.noAdminConfigured() &&
|
||||
(
|
||||
<Box bgcolor="error.main" color="error.contrastText" p={2} mt={2} mb={2}>
|
||||
<Typography variant="body1">
|
||||
You must have at least one admin user configured.
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
<FormActions>
|
||||
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit" disabled={this.noAdminConfigured()}>
|
||||
Save
|
||||
</FormButton>
|
||||
</FormActions>
|
||||
</ValidatorForm>
|
||||
{
|
||||
user &&
|
||||
<UserForm
|
||||
user={user}
|
||||
creating={creating}
|
||||
onDoneEditing={this.doneEditingUser}
|
||||
onCancelEditing={this.cancelEditingUser}
|
||||
handleValueChange={this.handleUserValueChange}
|
||||
uniqueUsername={this.uniqueUsername}
|
||||
/>
|
||||
}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withAuthenticatedContext(withWidth()(ManageUsersForm));
|
37
interface/src/security/Security.tsx
Normal file
37
interface/src/security/Security.tsx
Normal file
@ -0,0 +1,37 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom'
|
||||
|
||||
import { Tabs, Tab } from '@material-ui/core';
|
||||
|
||||
import { AuthenticatedContextProps, AuthenticatedRoute } from '../authentication';
|
||||
import { MenuAppBar } from '../components';
|
||||
|
||||
import ManageUsersController from './ManageUsersController';
|
||||
import SecuritySettingsController from './SecuritySettingsController';
|
||||
|
||||
type SecurityProps = AuthenticatedContextProps & RouteComponentProps;
|
||||
|
||||
class Security extends Component<SecurityProps> {
|
||||
|
||||
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
|
||||
this.props.history.push(path);
|
||||
};
|
||||
|
||||
render() {
|
||||
return (
|
||||
<MenuAppBar sectionTitle="Security">
|
||||
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth">
|
||||
<Tab value="/security/users" label="Manage Users" />
|
||||
<Tab value="/security/settings" label="Security Settings" />
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<AuthenticatedRoute exact path="/security/users" component={ManageUsersController} />
|
||||
<AuthenticatedRoute exact path="/security/settings" component={SecuritySettingsController} />
|
||||
<Redirect to="/security/users" />
|
||||
</Switch>
|
||||
</MenuAppBar>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default Security;
|
30
interface/src/security/SecuritySettingsController.tsx
Normal file
30
interface/src/security/SecuritySettingsController.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
|
||||
import { SECURITY_SETTINGS_ENDPOINT } from '../api';
|
||||
|
||||
import SecuritySettingsForm from './SecuritySettingsForm';
|
||||
import { SecuritySettings } from './types';
|
||||
|
||||
type SecuritySettingsControllerProps = RestControllerProps<SecuritySettings>;
|
||||
|
||||
class SecuritySettingsController extends Component<SecuritySettingsControllerProps> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title="Security Settings" titleGutter>
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={formProps => <SecuritySettingsForm {...formProps} />}
|
||||
/>
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default restController(SECURITY_SETTINGS_ENDPOINT, SecuritySettingsController);
|
52
interface/src/security/SecuritySettingsForm.tsx
Normal file
52
interface/src/security/SecuritySettingsForm.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import React from 'react';
|
||||
import { ValidatorForm } from 'react-material-ui-form-validator';
|
||||
|
||||
import { Box, Typography } from '@material-ui/core';
|
||||
import SaveIcon from '@material-ui/icons/Save';
|
||||
|
||||
import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication';
|
||||
import { RestFormProps, PasswordValidator, FormActions, FormButton } from '../components';
|
||||
|
||||
import { SecuritySettings } from './types';
|
||||
|
||||
type SecuritySettingsFormProps = RestFormProps<SecuritySettings> & AuthenticatedContextProps;
|
||||
|
||||
class SecuritySettingsForm extends React.Component<SecuritySettingsFormProps> {
|
||||
|
||||
onSubmit = () => {
|
||||
this.props.saveData();
|
||||
this.props.authenticatedContext.refresh();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { data, handleValueChange } = this.props;
|
||||
return (
|
||||
<ValidatorForm onSubmit={this.onSubmit}>
|
||||
<PasswordValidator
|
||||
validators={['required', 'matchRegexp:^.{1,64}$']}
|
||||
errorMessages={['JWT Secret Required', 'JWT Secret must be 64 characters or less']}
|
||||
name="jwt_secret"
|
||||
label="JWT Secret"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.jwt_secret}
|
||||
onChange={handleValueChange('jwt_secret')}
|
||||
margin="normal"
|
||||
/>
|
||||
<Box bgcolor="primary.main" color="primary.contrastText" p={2} mt={2} mb={2}>
|
||||
<Typography variant="body1">
|
||||
The JWT secret is used to sign authentication tokens. If you modify the JWT Secret, all users will be signed out.
|
||||
</Typography>
|
||||
</Box>
|
||||
<FormActions>
|
||||
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit">
|
||||
Save
|
||||
</FormButton>
|
||||
</FormActions>
|
||||
</ValidatorForm>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withAuthenticatedContext(SecuritySettingsForm);
|
86
interface/src/security/UserForm.tsx
Normal file
86
interface/src/security/UserForm.tsx
Normal file
@ -0,0 +1,86 @@
|
||||
import React, { RefObject } from 'react';
|
||||
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
|
||||
|
||||
import { Dialog, DialogTitle, DialogContent, DialogActions, Checkbox } from '@material-ui/core';
|
||||
|
||||
import { PasswordValidator, BlockFormControlLabel, FormButton } from '../components';
|
||||
|
||||
import { User } from './types';
|
||||
|
||||
interface UserFormProps {
|
||||
creating: boolean;
|
||||
user: User;
|
||||
uniqueUsername: (value: any) => boolean;
|
||||
handleValueChange: (name: keyof User) => (event: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onDoneEditing: () => void;
|
||||
onCancelEditing: () => void;
|
||||
}
|
||||
|
||||
class UserForm extends React.Component<UserFormProps> {
|
||||
|
||||
formRef: RefObject<any> = React.createRef();
|
||||
|
||||
componentDidMount() {
|
||||
ValidatorForm.addValidationRule('uniqueUsername', this.props.uniqueUsername);
|
||||
}
|
||||
|
||||
submit = () => {
|
||||
this.formRef.current.submit();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { user, creating, handleValueChange, onDoneEditing, onCancelEditing } = this.props;
|
||||
return (
|
||||
<ValidatorForm onSubmit={onDoneEditing} ref={this.formRef}>
|
||||
<Dialog onClose={onCancelEditing} aria-labelledby="user-form-dialog-title" open>
|
||||
<DialogTitle id="user-form-dialog-title">{creating ? 'Add' : 'Modify'} User</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
<TextValidator
|
||||
validators={creating ? ['required', 'uniqueUsername', 'matchRegexp:^[a-zA-Z0-9_\\.]{1,24}$'] : []}
|
||||
errorMessages={creating ? ['Username is required', "Username already exists", "Must be 1-24 characters: alpha numeric, '_' or '.'"] : []}
|
||||
name="username"
|
||||
label="Username"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={user.username}
|
||||
disabled={!creating}
|
||||
onChange={handleValueChange('username')}
|
||||
margin="normal"
|
||||
/>
|
||||
<PasswordValidator
|
||||
validators={['required', 'matchRegexp:^.{1,64}$']}
|
||||
errorMessages={['Password is required', 'Password must be 64 characters or less']}
|
||||
name="password"
|
||||
label="Password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={user.password}
|
||||
onChange={handleValueChange('password')}
|
||||
margin="normal"
|
||||
/>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
value="admin"
|
||||
checked={user.admin}
|
||||
onChange={handleValueChange('admin')}
|
||||
/>
|
||||
}
|
||||
label="Admin?"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<FormButton variant="contained" color="secondary" onClick={onCancelEditing}>
|
||||
Cancel
|
||||
</FormButton>
|
||||
<FormButton variant="contained" color="primary" type="submit" onClick={this.submit}>
|
||||
Done
|
||||
</FormButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
</ValidatorForm>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default UserForm;
|
11
interface/src/security/types.ts
Normal file
11
interface/src/security/types.ts
Normal file
@ -0,0 +1,11 @@
|
||||
export interface User {
|
||||
username: string;
|
||||
password: string;
|
||||
admin: boolean;
|
||||
}
|
||||
|
||||
export interface SecuritySettings {
|
||||
users: User[];
|
||||
jwt_secret: string;
|
||||
}
|
||||
|
145
interface/src/serviceWorker.ts
Normal file
145
interface/src/serviceWorker.ts
Normal file
@ -0,0 +1,145 @@
|
||||
// This optional code is used to register a service worker.
|
||||
// register() is not called by default.
|
||||
|
||||
// This lets the app load faster on subsequent visits in production, and gives
|
||||
// it offline capabilities. However, it also means that developers (and users)
|
||||
// will only see deployed updates on subsequent visits to a page, after all the
|
||||
// existing tabs open on the page have been closed, since previously cached
|
||||
// resources are updated in the background.
|
||||
|
||||
// To learn more about the benefits of this model and instructions on how to
|
||||
// opt-in, read https://bit.ly/CRA-PWA
|
||||
|
||||
const isLocalhost = Boolean(
|
||||
window.location.hostname === 'localhost' ||
|
||||
// [::1] is the IPv6 localhost address.
|
||||
window.location.hostname === '[::1]' ||
|
||||
// 127.0.0.0/8 are considered localhost for IPv4.
|
||||
window.location.hostname.match(
|
||||
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
|
||||
)
|
||||
);
|
||||
|
||||
type Config = {
|
||||
onSuccess?: (registration: ServiceWorkerRegistration) => void;
|
||||
onUpdate?: (registration: ServiceWorkerRegistration) => void;
|
||||
};
|
||||
|
||||
export function register(config?: Config) {
|
||||
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
|
||||
// The URL constructor is available in all browsers that support SW.
|
||||
const publicUrl = new URL(
|
||||
process.env.PUBLIC_URL,
|
||||
window.location.href
|
||||
);
|
||||
if (publicUrl.origin !== window.location.origin) {
|
||||
// Our service worker won't work if PUBLIC_URL is on a different origin
|
||||
// from what our page is served on. This might happen if a CDN is used to
|
||||
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
|
||||
|
||||
if (isLocalhost) {
|
||||
// This is running on localhost. Let's check if a service worker still exists or not.
|
||||
checkValidServiceWorker(swUrl, config);
|
||||
|
||||
// Add some additional logging to localhost, pointing developers to the
|
||||
// service worker/PWA documentation.
|
||||
navigator.serviceWorker.ready.then(() => {
|
||||
console.log(
|
||||
'This web app is being served cache-first by a service ' +
|
||||
'worker. To learn more, visit https://bit.ly/CRA-PWA'
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Is not localhost. Just register service worker
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function registerValidSW(swUrl: string, config?: Config) {
|
||||
navigator.serviceWorker
|
||||
.register(swUrl)
|
||||
.then(registration => {
|
||||
registration.onupdatefound = () => {
|
||||
const installingWorker = registration.installing;
|
||||
if (installingWorker == null) {
|
||||
return;
|
||||
}
|
||||
installingWorker.onstatechange = () => {
|
||||
if (installingWorker.state === 'installed') {
|
||||
if (navigator.serviceWorker.controller) {
|
||||
// At this point, the updated precached content has been fetched,
|
||||
// but the previous service worker will still serve the older
|
||||
// content until all client tabs are closed.
|
||||
console.log(
|
||||
'New content is available and will be used when all ' +
|
||||
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
|
||||
);
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onUpdate) {
|
||||
config.onUpdate(registration);
|
||||
}
|
||||
} else {
|
||||
// At this point, everything has been precached.
|
||||
// It's the perfect time to display a
|
||||
// "Content is cached for offline use." message.
|
||||
console.log('Content is cached for offline use.');
|
||||
|
||||
// Execute callback
|
||||
if (config && config.onSuccess) {
|
||||
config.onSuccess(registration);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error during service worker registration:', error);
|
||||
});
|
||||
}
|
||||
|
||||
function checkValidServiceWorker(swUrl: string, config?: Config) {
|
||||
// Check if the service worker can be found. If it can't reload the page.
|
||||
fetch(swUrl, {
|
||||
headers: { 'Service-Worker': 'script' }
|
||||
})
|
||||
.then(response => {
|
||||
// Ensure service worker exists, and that we really are getting a JS file.
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (
|
||||
response.status === 404 ||
|
||||
(contentType != null && contentType.indexOf('javascript') === -1)
|
||||
) {
|
||||
// No service worker found. Probably a different app. Reload the page.
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Service worker found. Proceed as normal.
|
||||
registerValidSW(swUrl, config);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
console.log(
|
||||
'No internet connection found. App is running in offline mode.'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function unregister() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.ready.then(registration => {
|
||||
registration.unregister();
|
||||
});
|
||||
}
|
||||
}
|
30
interface/src/system/OTASettingsController.tsx
Normal file
30
interface/src/system/OTASettingsController.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
|
||||
import { OTA_SETTINGS_ENDPOINT } from '../api';
|
||||
|
||||
import OTASettingsForm from './OTASettingsForm';
|
||||
import { OTASettings } from './types';
|
||||
|
||||
type OTASettingsControllerProps = RestControllerProps<OTASettings>;
|
||||
|
||||
class OTASettingsController extends Component<OTASettingsControllerProps> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title="OTA Settings" titleGutter>
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={formProps => <OTASettingsForm {...formProps} />}
|
||||
/>
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default restController(OTA_SETTINGS_ENDPOINT, OTASettingsController);
|
66
interface/src/system/OTASettingsForm.tsx
Normal file
66
interface/src/system/OTASettingsForm.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
|
||||
|
||||
import { Checkbox } from '@material-ui/core';
|
||||
import SaveIcon from '@material-ui/icons/Save';
|
||||
|
||||
import { RestFormProps, BlockFormControlLabel, PasswordValidator, FormButton, FormActions } from '../components';
|
||||
import {isIP,isHostname,or} from '../validators';
|
||||
|
||||
import { OTASettings } from './types';
|
||||
|
||||
type OTASettingsFormProps = RestFormProps<OTASettings>;
|
||||
|
||||
class OTASettingsForm extends React.Component<OTASettingsFormProps> {
|
||||
|
||||
componentDidMount() {
|
||||
ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
|
||||
}
|
||||
|
||||
render() {
|
||||
const { data, handleValueChange, saveData } = this.props;
|
||||
return (
|
||||
<ValidatorForm onSubmit={saveData}>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={data.enabled}
|
||||
onChange={handleValueChange("enabled")}
|
||||
/>
|
||||
}
|
||||
label="Enable OTA Updates?"
|
||||
/>
|
||||
<TextValidator
|
||||
validators={['required', 'isNumber', 'minNumber:1025', 'maxNumber:65535']}
|
||||
errorMessages={['Port is required', "Must be a number", "Must be greater than 1024 ", "Max value is 65535"]}
|
||||
name="port"
|
||||
label="Port"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.port}
|
||||
type="number"
|
||||
onChange={handleValueChange('port')}
|
||||
margin="normal"
|
||||
/>
|
||||
<PasswordValidator
|
||||
validators={['required', 'matchRegexp:^.{1,64}$']}
|
||||
errorMessages={['OTA Password is required', 'OTA Point Password must be 64 characters or less']}
|
||||
name="password"
|
||||
label="Password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.password}
|
||||
onChange={handleValueChange('password')}
|
||||
margin="normal"
|
||||
/>
|
||||
<FormActions>
|
||||
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit">
|
||||
Save
|
||||
</FormButton>
|
||||
</FormActions>
|
||||
</ValidatorForm>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default OTASettingsForm;
|
51
interface/src/system/System.tsx
Normal file
51
interface/src/system/System.tsx
Normal file
@ -0,0 +1,51 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom'
|
||||
|
||||
import { Tabs, Tab } from '@material-ui/core';
|
||||
|
||||
import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext';
|
||||
|
||||
import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication';
|
||||
import { MenuAppBar } from '../components';
|
||||
|
||||
import SystemStatusController from './SystemStatusController';
|
||||
import OTASettingsController from './OTASettingsController';
|
||||
import UploadFirmwareController from './UploadFirmwareController';
|
||||
|
||||
type SystemProps = AuthenticatedContextProps & RouteComponentProps & WithFeaturesProps;
|
||||
|
||||
class System extends Component<SystemProps> {
|
||||
|
||||
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
|
||||
this.props.history.push(path);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { authenticatedContext, features } = this.props;
|
||||
return (
|
||||
<MenuAppBar sectionTitle="System">
|
||||
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth">
|
||||
<Tab value="/system/status" label="System Status" />
|
||||
{features.ota && (
|
||||
<Tab value="/system/ota" label="OTA Settings" disabled={!authenticatedContext.me.admin} />
|
||||
)}
|
||||
{features.upload_firmware && (
|
||||
<Tab value="/system/upload" label="Upload Firmware" disabled={!authenticatedContext.me.admin} />
|
||||
)}
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<AuthenticatedRoute exact path="/system/status" component={SystemStatusController} />
|
||||
{features.ota && (
|
||||
<AuthenticatedRoute exact path="/system/ota" component={OTASettingsController} />
|
||||
)}
|
||||
{features.upload_firmware && (
|
||||
<AuthenticatedRoute exact path="/system/upload" component={UploadFirmwareController} />
|
||||
)}
|
||||
<Redirect to="/system/status" />
|
||||
</Switch>
|
||||
</MenuAppBar>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withFeatures(withAuthenticatedContext(System));
|
30
interface/src/system/SystemStatusController.tsx
Normal file
30
interface/src/system/SystemStatusController.tsx
Normal file
@ -0,0 +1,30 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
|
||||
import { SYSTEM_STATUS_ENDPOINT } from '../api';
|
||||
|
||||
import SystemStatusForm from './SystemStatusForm';
|
||||
import { SystemStatus } from './types';
|
||||
|
||||
type SystemStatusControllerProps = RestControllerProps<SystemStatus>;
|
||||
|
||||
class SystemStatusController extends Component<SystemStatusControllerProps> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title="System Status">
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={formProps => <SystemStatusForm {...formProps} />}
|
||||
/>
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default restController(SYSTEM_STATUS_ENDPOINT, SystemStatusController);
|
245
interface/src/system/SystemStatusForm.tsx
Normal file
245
interface/src/system/SystemStatusForm.tsx
Normal file
@ -0,0 +1,245 @@
|
||||
import React, { Component, Fragment } from 'react';
|
||||
|
||||
import { Avatar, Button, Divider, Dialog, DialogTitle, DialogContent, DialogActions, Box } from '@material-ui/core';
|
||||
import { List, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core';
|
||||
|
||||
import DevicesIcon from '@material-ui/icons/Devices';
|
||||
import MemoryIcon from '@material-ui/icons/Memory';
|
||||
import ShowChartIcon from '@material-ui/icons/ShowChart';
|
||||
import SdStorageIcon from '@material-ui/icons/SdStorage';
|
||||
import FolderIcon from '@material-ui/icons/Folder';
|
||||
import DataUsageIcon from '@material-ui/icons/DataUsage';
|
||||
import AppsIcon from '@material-ui/icons/Apps';
|
||||
import PowerSettingsNewIcon from '@material-ui/icons/PowerSettingsNew';
|
||||
import RefreshIcon from '@material-ui/icons/Refresh';
|
||||
import SettingsBackupRestoreIcon from '@material-ui/icons/SettingsBackupRestore';
|
||||
|
||||
import { redirectingAuthorizedFetch, AuthenticatedContextProps, withAuthenticatedContext } from '../authentication';
|
||||
import { RestFormProps, FormButton, ErrorButton } from '../components';
|
||||
import { FACTORY_RESET_ENDPOINT, RESTART_ENDPOINT } from '../api';
|
||||
|
||||
import { SystemStatus, EspPlatform } from './types';
|
||||
|
||||
interface SystemStatusFormState {
|
||||
confirmRestart: boolean;
|
||||
confirmFactoryReset: boolean;
|
||||
processing: boolean;
|
||||
}
|
||||
|
||||
type SystemStatusFormProps = AuthenticatedContextProps & RestFormProps<SystemStatus>;
|
||||
|
||||
function formatNumber(num: number) {
|
||||
return new Intl.NumberFormat().format(num);
|
||||
}
|
||||
|
||||
class SystemStatusForm extends Component<SystemStatusFormProps, SystemStatusFormState> {
|
||||
|
||||
state: SystemStatusFormState = {
|
||||
confirmRestart: false,
|
||||
confirmFactoryReset: false,
|
||||
processing: false
|
||||
}
|
||||
|
||||
createListItems() {
|
||||
const { data } = this.props
|
||||
return (
|
||||
<Fragment>
|
||||
<ListItem >
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<DevicesIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Device (Platform / SDK)" secondary={data.esp_platform + ' / ' + data.sdk_version} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem >
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<ShowChartIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="CPU Frequency" secondary={data.cpu_freq_mhz + ' MHz'} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem >
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<MemoryIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Heap (Free / Max Alloc)" secondary={formatNumber(data.free_heap) + ' / ' + formatNumber(data.max_alloc_heap) + ' bytes ' + (data.esp_platform === EspPlatform.ESP8266 ? '(' + data.heap_fragmentation + '% fragmentation)' : '')} />
|
||||
</ListItem>
|
||||
{
|
||||
(data.esp_platform === EspPlatform.ESP32 && data.psram_size > 0) && (
|
||||
<Fragment>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem >
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<AppsIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="PSRAM (Size / Free)" secondary={formatNumber(data.psram_size) + ' / ' + formatNumber(data.free_psram) + ' bytes'} />
|
||||
</ListItem>
|
||||
</Fragment>)
|
||||
}
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem >
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<DataUsageIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Sketch (Size / Free)" secondary={formatNumber(data.sketch_size) + ' / ' + formatNumber(data.free_sketch_space) + ' bytes'} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem >
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<SdStorageIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Flash Chip (Size / Speed)" secondary={formatNumber(data.flash_chip_size) + ' bytes / ' + (data.flash_chip_speed / 1000000).toFixed(0) + ' MHz'} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem >
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<FolderIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="File System (Used / Total)" secondary={formatNumber(data.fs_used) + ' / ' + formatNumber(data.fs_total) + ' bytes (' + formatNumber(data.fs_total - data.fs_used) + '\xa0bytes free)'} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
renderRestartDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
open={this.state.confirmRestart}
|
||||
onClose={this.onRestartRejected}
|
||||
>
|
||||
<DialogTitle>Confirm Restart</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
Are you sure you want to restart the device?
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="contained" onClick={this.onRestartRejected} color="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<Button startIcon={<PowerSettingsNewIcon />} variant="contained" onClick={this.onRestartConfirmed} disabled={this.state.processing} color="primary" autoFocus>
|
||||
Restart
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
onRestart = () => {
|
||||
this.setState({ confirmRestart: true });
|
||||
}
|
||||
|
||||
onRestartRejected = () => {
|
||||
this.setState({ confirmRestart: false });
|
||||
}
|
||||
|
||||
onRestartConfirmed = () => {
|
||||
this.setState({ processing: true });
|
||||
redirectingAuthorizedFetch(RESTART_ENDPOINT, { method: 'POST' })
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
this.props.enqueueSnackbar("Device is restarting", { variant: 'info' });
|
||||
this.setState({ processing: false, confirmRestart: false });
|
||||
} else {
|
||||
throw Error("Invalid status code: " + response.status);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.props.enqueueSnackbar(error.message || "Problem restarting device", { variant: 'error' });
|
||||
this.setState({ processing: false, confirmRestart: false });
|
||||
});
|
||||
}
|
||||
|
||||
renderFactoryResetDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
open={this.state.confirmFactoryReset}
|
||||
onClose={this.onFactoryResetRejected}
|
||||
>
|
||||
<DialogTitle>Confirm Factory Reset</DialogTitle>
|
||||
<DialogContent dividers>
|
||||
Are you sure you want to reset the device to its factory defaults?
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button variant="contained" onClick={this.onFactoryResetRejected} color="secondary">
|
||||
Cancel
|
||||
</Button>
|
||||
<ErrorButton startIcon={<SettingsBackupRestoreIcon />} variant="contained" onClick={this.onFactoryResetConfirmed} disabled={this.state.processing} autoFocus>
|
||||
Factory Reset
|
||||
</ErrorButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
onFactoryReset = () => {
|
||||
this.setState({ confirmFactoryReset: true });
|
||||
}
|
||||
|
||||
onFactoryResetRejected = () => {
|
||||
this.setState({ confirmFactoryReset: false });
|
||||
}
|
||||
|
||||
onFactoryResetConfirmed = () => {
|
||||
this.setState({ processing: true });
|
||||
redirectingAuthorizedFetch(FACTORY_RESET_ENDPOINT, { method: 'POST' })
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
this.props.enqueueSnackbar("Factory reset in progress.", { variant: 'error' });
|
||||
this.setState({ processing: false, confirmFactoryReset: false });
|
||||
} else {
|
||||
throw Error("Invalid status code: " + response.status);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
this.props.enqueueSnackbar(error.message || "Problem factory resetting device", { variant: 'error' });
|
||||
this.setState({ processing: false, confirmRestart: false });
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const me = this.props.authenticatedContext.me;
|
||||
return (
|
||||
<Fragment>
|
||||
<List>
|
||||
{this.createListItems()}
|
||||
</List>
|
||||
<Box display="flex" flexWrap="wrap">
|
||||
<Box flexGrow={1} padding={1}>
|
||||
<FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}>
|
||||
Refresh
|
||||
</FormButton>
|
||||
</Box>
|
||||
{me.admin &&
|
||||
<Box flexWrap="none" padding={1} whiteSpace="nowrap">
|
||||
<FormButton startIcon={<PowerSettingsNewIcon />} variant="contained" color="primary" onClick={this.onRestart}>
|
||||
Restart
|
||||
</FormButton>
|
||||
<ErrorButton startIcon={<SettingsBackupRestoreIcon />} variant="contained" onClick={this.onFactoryReset}>
|
||||
Factory reset
|
||||
</ErrorButton>
|
||||
</Box>
|
||||
}
|
||||
</Box>
|
||||
{this.renderRestartDialog()}
|
||||
{this.renderFactoryResetDialog()}
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withAuthenticatedContext(SystemStatusForm);
|
71
interface/src/system/UploadFirmwareController.tsx
Normal file
71
interface/src/system/UploadFirmwareController.tsx
Normal file
@ -0,0 +1,71 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { SectionContent } from '../components';
|
||||
import { UPLOAD_FIRMWARE_ENDPOINT } from '../api';
|
||||
|
||||
import UploadFirmwareForm from './UploadFirmwareForm';
|
||||
import { redirectingAuthorizedUpload } from '../authentication';
|
||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
||||
|
||||
interface UploadFirmwareControllerState {
|
||||
xhr?: XMLHttpRequest;
|
||||
progress?: ProgressEvent;
|
||||
}
|
||||
|
||||
class UploadFirmwareController extends Component<WithSnackbarProps, UploadFirmwareControllerState> {
|
||||
|
||||
state: UploadFirmwareControllerState = {
|
||||
xhr: undefined,
|
||||
progress: undefined
|
||||
};
|
||||
|
||||
componentWillUnmount() {
|
||||
this.state.xhr?.abort();
|
||||
}
|
||||
|
||||
updateProgress = (progress: ProgressEvent) => {
|
||||
this.setState({ progress });
|
||||
}
|
||||
|
||||
uploadFile = (file: File) => {
|
||||
if (this.state.xhr) {
|
||||
return;
|
||||
}
|
||||
var xhr = new XMLHttpRequest();
|
||||
this.setState({ xhr });
|
||||
redirectingAuthorizedUpload(xhr, UPLOAD_FIRMWARE_ENDPOINT, file, this.updateProgress).then(() => {
|
||||
if (xhr.status !== 200) {
|
||||
throw Error("Invalid status code: " + xhr.status);
|
||||
}
|
||||
this.props.enqueueSnackbar("Activating new firmware", { variant: 'success' });
|
||||
this.setState({ xhr: undefined, progress: undefined });
|
||||
}).catch((error: Error) => {
|
||||
if (error.name === 'AbortError') {
|
||||
this.props.enqueueSnackbar("Upload cancelled by user", { variant: 'warning' });
|
||||
} else {
|
||||
const errorMessage = error.name === 'UploadError' ? "Error during upload" : (error.message || "Unknown error");
|
||||
this.props.enqueueSnackbar("Problem uploading: " + errorMessage, { variant: 'error' });
|
||||
this.setState({ xhr: undefined, progress: undefined });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
cancelUpload = () => {
|
||||
if (this.state.xhr) {
|
||||
this.state.xhr.abort();
|
||||
this.setState({ xhr: undefined, progress: undefined });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { xhr, progress } = this.state;
|
||||
return (
|
||||
<SectionContent title="Upload Firmware">
|
||||
<UploadFirmwareForm onFileSelected={this.uploadFile} onCancel={this.cancelUpload} uploading={!!xhr} progress={progress} />
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withSnackbar(UploadFirmwareController);
|
35
interface/src/system/UploadFirmwareForm.tsx
Normal file
35
interface/src/system/UploadFirmwareForm.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { SingleUpload } from '../components';
|
||||
import { Box } from '@material-ui/core';
|
||||
|
||||
interface UploadFirmwareFormProps {
|
||||
uploading: boolean;
|
||||
progress?: ProgressEvent;
|
||||
onFileSelected: (file: File) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
class UploadFirmwareForm extends React.Component<UploadFirmwareFormProps> {
|
||||
|
||||
handleDrop = (files: File[]) => {
|
||||
const file = files[0];
|
||||
if (file) {
|
||||
this.props.onFileSelected(files[0]);
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
const { uploading, progress, onCancel } = this.props;
|
||||
return (
|
||||
<Fragment>
|
||||
<Box py={2}>
|
||||
Upload a new firmware (.bin) file below to replace the existing firmware.
|
||||
</Box>
|
||||
<SingleUpload accept="application/octet-stream" onDrop={this.handleDrop} uploading={uploading} progress={progress} onCancel={onCancel} />
|
||||
</Fragment>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default UploadFirmwareForm;
|
37
interface/src/system/types.ts
Normal file
37
interface/src/system/types.ts
Normal file
@ -0,0 +1,37 @@
|
||||
export enum EspPlatform {
|
||||
ESP8266 = "esp8266",
|
||||
ESP32 = "esp32"
|
||||
}
|
||||
|
||||
interface ESPSystemStatus {
|
||||
esp_platform: EspPlatform;
|
||||
max_alloc_heap: number;
|
||||
cpu_freq_mhz: number;
|
||||
free_heap: number;
|
||||
sketch_size: number;
|
||||
free_sketch_space: number;
|
||||
sdk_version: string;
|
||||
flash_chip_size: number;
|
||||
flash_chip_speed: number;
|
||||
fs_used: number;
|
||||
fs_total: number;
|
||||
}
|
||||
|
||||
export interface ESP32SystemStatus extends ESPSystemStatus {
|
||||
esp_platform: EspPlatform.ESP32;
|
||||
psram_size: number;
|
||||
free_psram: number;
|
||||
}
|
||||
|
||||
export interface ESP8266SystemStatus extends ESPSystemStatus {
|
||||
esp_platform: EspPlatform.ESP8266;
|
||||
heap_fragmentation: number;
|
||||
}
|
||||
|
||||
export type SystemStatus = ESP8266SystemStatus | ESP32SystemStatus;
|
||||
|
||||
export interface OTASettings {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
password: string;
|
||||
}
|
4
interface/src/validators/index.ts
Normal file
4
interface/src/validators/index.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export { default as isHostname } from './isHostname';
|
||||
export { default as isIP } from './isIP';
|
||||
export { default as optional } from './optional';
|
||||
export { default as or } from './or';
|
6
interface/src/validators/isHostname.ts
Normal file
6
interface/src/validators/isHostname.ts
Normal file
@ -0,0 +1,6 @@
|
||||
const hostnameLengthRegex = /^.{0,32}$/
|
||||
const hostnamePatternRegex = /^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9-]*[A-Za-z0-9])$/
|
||||
|
||||
export default function isHostname(hostname: string) {
|
||||
return hostnameLengthRegex.test(hostname) && hostnamePatternRegex.test(hostname);
|
||||
}
|
5
interface/src/validators/isIP.ts
Normal file
5
interface/src/validators/isIP.ts
Normal file
@ -0,0 +1,5 @@
|
||||
const ipAddressRegexp = /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/
|
||||
|
||||
export default function isIp(ipAddress: string) {
|
||||
return ipAddressRegexp.test(ipAddress);
|
||||
}
|
1
interface/src/validators/optional.ts
Normal file
1
interface/src/validators/optional.ts
Normal file
@ -0,0 +1 @@
|
||||
export default (validator: (value: any) => boolean) => (value: any) => !value || validator(value);
|
3
interface/src/validators/or.ts
Normal file
3
interface/src/validators/or.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export default (validator1: (value: any) => boolean, validator2: (value: any) => boolean) => {
|
||||
return (value: any) => validator1(value) || validator2(value);
|
||||
}
|
62
interface/src/wifi/WiFiConnection.tsx
Normal file
62
interface/src/wifi/WiFiConnection.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import React, { Component } from 'react';
|
||||
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom'
|
||||
|
||||
import { Tabs, Tab } from '@material-ui/core';
|
||||
|
||||
import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication';
|
||||
import { MenuAppBar } from '../components';
|
||||
|
||||
import WiFiStatusController from './WiFiStatusController';
|
||||
import WiFiSettingsController from './WiFiSettingsController';
|
||||
import WiFiNetworkScanner from './WiFiNetworkScanner';
|
||||
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
||||
import { WiFiNetwork } from './types';
|
||||
|
||||
type WiFiConnectionProps = AuthenticatedContextProps & RouteComponentProps;
|
||||
|
||||
class WiFiConnection extends Component<WiFiConnectionProps, WiFiConnectionContext> {
|
||||
|
||||
constructor(props: WiFiConnectionProps) {
|
||||
super(props);
|
||||
this.state = {
|
||||
selectNetwork: this.selectNetwork,
|
||||
deselectNetwork: this.deselectNetwork
|
||||
};
|
||||
}
|
||||
|
||||
selectNetwork = (network: WiFiNetwork) => {
|
||||
this.setState({ selectedNetwork: network });
|
||||
this.props.history.push('/wifi/settings');
|
||||
}
|
||||
|
||||
deselectNetwork = () => {
|
||||
this.setState({ selectedNetwork: undefined });
|
||||
}
|
||||
|
||||
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
|
||||
this.props.history.push(path);
|
||||
};
|
||||
|
||||
render() {
|
||||
const { authenticatedContext } = this.props;
|
||||
return (
|
||||
<WiFiConnectionContext.Provider value={this.state}>
|
||||
<MenuAppBar sectionTitle="WiFi Connection">
|
||||
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth">
|
||||
<Tab value="/wifi/status" label="WiFi Status" />
|
||||
<Tab value="/wifi/scan" label="Scan Networks" disabled={!authenticatedContext.me.admin} />
|
||||
<Tab value="/wifi/settings" label="WiFi Settings" disabled={!authenticatedContext.me.admin} />
|
||||
</Tabs>
|
||||
<Switch>
|
||||
<AuthenticatedRoute exact path="/wifi/status" component={WiFiStatusController} />
|
||||
<AuthenticatedRoute exact path="/wifi/scan" component={WiFiNetworkScanner} />
|
||||
<AuthenticatedRoute exact path="/wifi/settings" component={WiFiSettingsController} />
|
||||
<Redirect to="/wifi/status" />
|
||||
</Switch>
|
||||
</MenuAppBar>
|
||||
</WiFiConnectionContext.Provider>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default withAuthenticatedContext(WiFiConnection);
|
13
interface/src/wifi/WiFiConnectionContext.tsx
Normal file
13
interface/src/wifi/WiFiConnectionContext.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import { WiFiNetwork } from './types';
|
||||
|
||||
export interface WiFiConnectionContext {
|
||||
selectedNetwork?: WiFiNetwork;
|
||||
selectNetwork: (network: WiFiNetwork) => void;
|
||||
deselectNetwork: () => void;
|
||||
}
|
||||
|
||||
const WiFiConnectionContextDefaultValue = {} as WiFiConnectionContext
|
||||
export const WiFiConnectionContext = React.createContext(
|
||||
WiFiConnectionContextDefaultValue
|
||||
);
|
168
interface/src/wifi/WiFiNetworkScanner.tsx
Normal file
168
interface/src/wifi/WiFiNetworkScanner.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import React, { Component } from 'react';
|
||||
import { withSnackbar, WithSnackbarProps } from 'notistack';
|
||||
|
||||
import { createStyles, WithStyles, Theme, withStyles, Typography, LinearProgress } from '@material-ui/core';
|
||||
import PermScanWifiIcon from '@material-ui/icons/PermScanWifi';
|
||||
|
||||
import { FormActions, FormButton, SectionContent } from '../components';
|
||||
import { redirectingAuthorizedFetch } from '../authentication';
|
||||
import { SCAN_NETWORKS_ENDPOINT, LIST_NETWORKS_ENDPOINT } from '../api';
|
||||
|
||||
import WiFiNetworkSelector from './WiFiNetworkSelector';
|
||||
import { WiFiNetworkList, WiFiNetwork } from './types';
|
||||
|
||||
const NUM_POLLS = 10
|
||||
const POLLING_FREQUENCY = 500
|
||||
const RETRY_EXCEPTION_TYPE = "retry"
|
||||
|
||||
interface WiFiNetworkScannerState {
|
||||
scanningForNetworks: boolean;
|
||||
errorMessage?: string;
|
||||
networkList?: WiFiNetworkList;
|
||||
}
|
||||
|
||||
const styles = (theme: Theme) => createStyles({
|
||||
scanningSettings: {
|
||||
margin: theme.spacing(0.5),
|
||||
},
|
||||
scanningSettingsDetails: {
|
||||
margin: theme.spacing(4),
|
||||
textAlign: "center"
|
||||
},
|
||||
scanningProgress: {
|
||||
margin: theme.spacing(4),
|
||||
textAlign: "center"
|
||||
}
|
||||
});
|
||||
|
||||
type WiFiNetworkScannerProps = WithSnackbarProps & WithStyles<typeof styles>;
|
||||
|
||||
class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkScannerState> {
|
||||
|
||||
pollCount: number = 0;
|
||||
|
||||
state: WiFiNetworkScannerState = {
|
||||
scanningForNetworks: false,
|
||||
};
|
||||
|
||||
componentDidMount() {
|
||||
this.scanNetworks();
|
||||
}
|
||||
|
||||
requestNetworkScan = () => {
|
||||
const { scanningForNetworks } = this.state;
|
||||
if (!scanningForNetworks) {
|
||||
this.scanNetworks();
|
||||
}
|
||||
}
|
||||
|
||||
scanNetworks() {
|
||||
this.pollCount = 0;
|
||||
this.setState({ scanningForNetworks: true, networkList: undefined, errorMessage: undefined });
|
||||
redirectingAuthorizedFetch(SCAN_NETWORKS_ENDPOINT).then(response => {
|
||||
if (response.status === 202) {
|
||||
this.schedulePollTimeout();
|
||||
return;
|
||||
}
|
||||
throw Error("Scanning for networks returned unexpected response code: " + response.status);
|
||||
}).catch(error => {
|
||||
this.props.enqueueSnackbar("Problem scanning: " + error.message, {
|
||||
variant: 'error',
|
||||
});
|
||||
this.setState({ scanningForNetworks: false, networkList: undefined, errorMessage: error.message });
|
||||
});
|
||||
}
|
||||
|
||||
schedulePollTimeout() {
|
||||
setTimeout(this.pollNetworkList, POLLING_FREQUENCY);
|
||||
}
|
||||
|
||||
retryError() {
|
||||
return {
|
||||
name: RETRY_EXCEPTION_TYPE,
|
||||
message: "Network list not ready, will retry in " + POLLING_FREQUENCY + "ms."
|
||||
};
|
||||
}
|
||||
|
||||
compareNetworks(network1: WiFiNetwork, network2: WiFiNetwork) {
|
||||
if (network1.rssi < network2.rssi)
|
||||
return 1;
|
||||
if (network1.rssi > network2.rssi)
|
||||
return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
pollNetworkList = () => {
|
||||
redirectingAuthorizedFetch(LIST_NETWORKS_ENDPOINT)
|
||||
.then(response => {
|
||||
if (response.status === 200) {
|
||||
return response.json();
|
||||
}
|
||||
if (response.status === 202) {
|
||||
if (++this.pollCount < NUM_POLLS) {
|
||||
this.schedulePollTimeout();
|
||||
throw this.retryError();
|
||||
} else {
|
||||
throw Error("Device did not return network list in timely manner.");
|
||||
}
|
||||
}
|
||||
throw Error("Device returned unexpected response code: " + response.status);
|
||||
})
|
||||
.then(json => {
|
||||
json.networks.sort(this.compareNetworks)
|
||||
this.setState({ scanningForNetworks: false, networkList: json, errorMessage: undefined })
|
||||
})
|
||||
.catch(error => {
|
||||
if (error.name !== RETRY_EXCEPTION_TYPE) {
|
||||
this.props.enqueueSnackbar("Problem scanning: " + error.message, {
|
||||
variant: 'error',
|
||||
});
|
||||
this.setState({ scanningForNetworks: false, networkList: undefined, errorMessage: error.message });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderNetworkScanner() {
|
||||
const { classes } = this.props;
|
||||
const { scanningForNetworks, networkList, errorMessage } = this.state;
|
||||
if (scanningForNetworks || !networkList) {
|
||||
return (
|
||||
<div className={classes.scanningSettings}>
|
||||
<LinearProgress className={classes.scanningSettingsDetails} />
|
||||
<Typography variant="h6" className={classes.scanningProgress}>
|
||||
Scanning…
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (errorMessage) {
|
||||
return (
|
||||
<div className={classes.scanningSettings}>
|
||||
<Typography variant="h6" className={classes.scanningSettingsDetails}>
|
||||
{errorMessage}
|
||||
</Typography>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<WiFiNetworkSelector networkList={networkList} />
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { scanningForNetworks } = this.state;
|
||||
return (
|
||||
<SectionContent title="Network Scanner">
|
||||
{this.renderNetworkScanner()}
|
||||
<FormActions>
|
||||
<FormButton startIcon={<PermScanWifiIcon />} variant="contained" color="secondary" onClick={this.requestNetworkScan} disabled={scanningForNetworks}>
|
||||
Scan again…
|
||||
</FormButton>
|
||||
</FormActions>
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default withSnackbar(withStyles(styles)(WiFiNetworkScanner));
|
54
interface/src/wifi/WiFiNetworkSelector.tsx
Normal file
54
interface/src/wifi/WiFiNetworkSelector.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { Avatar, Badge } from '@material-ui/core';
|
||||
import { List, ListItem, ListItemIcon, ListItemText, ListItemAvatar } from '@material-ui/core';
|
||||
|
||||
import WifiIcon from '@material-ui/icons/Wifi';
|
||||
import LockIcon from '@material-ui/icons/Lock';
|
||||
import LockOpenIcon from '@material-ui/icons/LockOpen';
|
||||
|
||||
import { isNetworkOpen, networkSecurityMode } from './WiFiSecurityModes';
|
||||
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
||||
import { WiFiNetwork, WiFiNetworkList } from './types';
|
||||
|
||||
interface WiFiNetworkSelectorProps {
|
||||
networkList: WiFiNetworkList;
|
||||
}
|
||||
|
||||
class WiFiNetworkSelector extends Component<WiFiNetworkSelectorProps> {
|
||||
|
||||
static contextType = WiFiConnectionContext;
|
||||
context!: React.ContextType<typeof WiFiConnectionContext>;
|
||||
|
||||
renderNetwork = (network: WiFiNetwork) => {
|
||||
return (
|
||||
<ListItem key={network.bssid} button onClick={() => this.context.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>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<List>
|
||||
{this.props.networkList.networks.map(this.renderNetwork)}
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default WiFiNetworkSelector;
|
21
interface/src/wifi/WiFiSecurityModes.ts
Normal file
21
interface/src/wifi/WiFiSecurityModes.ts
Normal file
@ -0,0 +1,21 @@
|
||||
import { WiFiNetwork, WiFiEncryptionType } from "./types";
|
||||
|
||||
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";
|
||||
}
|
||||
}
|
29
interface/src/wifi/WiFiSettingsController.tsx
Normal file
29
interface/src/wifi/WiFiSettingsController.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
|
||||
import WiFiSettingsForm from './WiFiSettingsForm';
|
||||
import { WIFI_SETTINGS_ENDPOINT } from '../api';
|
||||
import { WiFiSettings } from './types';
|
||||
|
||||
type WiFiSettingsControllerProps = RestControllerProps<WiFiSettings>;
|
||||
|
||||
class WiFiSettingsController extends Component<WiFiSettingsControllerProps> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title="WiFi Settings">
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={formProps => <WiFiSettingsForm {...formProps} />}
|
||||
/>
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default restController(WIFI_SETTINGS_ENDPOINT, WiFiSettingsController);
|
200
interface/src/wifi/WiFiSettingsForm.tsx
Normal file
200
interface/src/wifi/WiFiSettingsForm.tsx
Normal file
@ -0,0 +1,200 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
|
||||
|
||||
import { Checkbox, List, ListItem, ListItemText, ListItemAvatar, ListItemSecondaryAction } from '@material-ui/core';
|
||||
|
||||
import Avatar from '@material-ui/core/Avatar';
|
||||
import IconButton from '@material-ui/core/IconButton';
|
||||
import LockIcon from '@material-ui/icons/Lock';
|
||||
import LockOpenIcon from '@material-ui/icons/LockOpen';
|
||||
import DeleteIcon from '@material-ui/icons/Delete';
|
||||
import SaveIcon from '@material-ui/icons/Save';
|
||||
|
||||
import { RestFormProps, PasswordValidator, BlockFormControlLabel, FormActions, FormButton } from '../components';
|
||||
import { isIP, isHostname, optional } from '../validators';
|
||||
|
||||
import { WiFiConnectionContext } from './WiFiConnectionContext';
|
||||
import { isNetworkOpen, networkSecurityMode } from './WiFiSecurityModes';
|
||||
import { WiFiSettings } from './types';
|
||||
|
||||
type WiFiStatusFormProps = RestFormProps<WiFiSettings>;
|
||||
|
||||
class WiFiSettingsForm extends React.Component<WiFiStatusFormProps> {
|
||||
|
||||
static contextType = WiFiConnectionContext;
|
||||
context!: React.ContextType<typeof WiFiConnectionContext>;
|
||||
|
||||
constructor(props: WiFiStatusFormProps, context: WiFiConnectionContext) {
|
||||
super(props);
|
||||
|
||||
const { selectedNetwork } = context;
|
||||
if (selectedNetwork) {
|
||||
const wifiSettings: WiFiSettings = {
|
||||
ssid: selectedNetwork.ssid,
|
||||
password: "",
|
||||
hostname: props.data.hostname,
|
||||
static_ip_config: false,
|
||||
}
|
||||
props.setData(wifiSettings);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
ValidatorForm.addValidationRule('isIP', isIP);
|
||||
ValidatorForm.addValidationRule('isHostname', isHostname);
|
||||
ValidatorForm.addValidationRule('isOptionalIP', optional(isIP));
|
||||
}
|
||||
|
||||
deselectNetworkAndLoadData = () => {
|
||||
this.context.deselectNetwork();
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.context.deselectNetwork();
|
||||
}
|
||||
|
||||
render() {
|
||||
const { selectedNetwork, deselectNetwork } = this.context;
|
||||
const { data, handleValueChange, saveData } = this.props;
|
||||
return (
|
||||
<ValidatorForm onSubmit={saveData} ref="WiFiSettingsForm">
|
||||
{
|
||||
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>
|
||||
:
|
||||
<TextValidator
|
||||
validators={['matchRegexp:^.{0,32}$']}
|
||||
errorMessages={['SSID must be 32 characters or less']}
|
||||
name="ssid"
|
||||
label="SSID"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.ssid}
|
||||
onChange={handleValueChange('ssid')}
|
||||
margin="normal"
|
||||
/>
|
||||
}
|
||||
{
|
||||
(!selectedNetwork || !isNetworkOpen(selectedNetwork)) &&
|
||||
<PasswordValidator
|
||||
validators={['matchRegexp:^.{0,64}$']}
|
||||
errorMessages={['Password must be 64 characters or less']}
|
||||
name="password"
|
||||
label="Password"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.password}
|
||||
onChange={handleValueChange('password')}
|
||||
margin="normal"
|
||||
/>
|
||||
}
|
||||
<TextValidator
|
||||
validators={['required', 'isHostname']}
|
||||
errorMessages={['Hostname is required', "Not a valid hostname"]}
|
||||
name="hostname"
|
||||
label="Hostname"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.hostname}
|
||||
onChange={handleValueChange('hostname')}
|
||||
margin="normal"
|
||||
/>
|
||||
<BlockFormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
value="static_ip_config"
|
||||
checked={data.static_ip_config}
|
||||
onChange={handleValueChange("static_ip_config")}
|
||||
/>
|
||||
}
|
||||
label="Static IP Config?"
|
||||
/>
|
||||
{
|
||||
data.static_ip_config &&
|
||||
<Fragment>
|
||||
<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"
|
||||
/>
|
||||
<TextValidator
|
||||
validators={['isOptionalIP']}
|
||||
errorMessages={['Must be an IP address']}
|
||||
name="dns_ip_1"
|
||||
label="DNS IP #1"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.dns_ip_1}
|
||||
onChange={handleValueChange('dns_ip_1')}
|
||||
margin="normal"
|
||||
/>
|
||||
<TextValidator
|
||||
validators={['isOptionalIP']}
|
||||
errorMessages={['Must be an IP address']}
|
||||
name="dns_ip_2"
|
||||
label="DNS IP #2"
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
value={data.dns_ip_2}
|
||||
onChange={handleValueChange('dns_ip_2')}
|
||||
margin="normal"
|
||||
/>
|
||||
</Fragment>
|
||||
}
|
||||
<FormActions>
|
||||
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit">
|
||||
Save
|
||||
</FormButton>
|
||||
</FormActions>
|
||||
</ValidatorForm>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default WiFiSettingsForm;
|
41
interface/src/wifi/WiFiStatus.ts
Normal file
41
interface/src/wifi/WiFiStatus.ts
Normal file
@ -0,0 +1,41 @@
|
||||
import { Theme } from '@material-ui/core';
|
||||
import { WiFiStatus, WiFiConnectionStatus } from './types';
|
||||
|
||||
export const isConnected = ({ status }: WiFiStatus) => status === WiFiConnectionStatus.WIFI_STATUS_CONNECTED;
|
||||
|
||||
export const wifiStatusHighlight = ({ status }: WiFiStatus, theme: Theme) => {
|
||||
switch (status) {
|
||||
case WiFiConnectionStatus.WIFI_STATUS_IDLE:
|
||||
case WiFiConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
||||
case WiFiConnectionStatus.WIFI_STATUS_NO_SHIELD:
|
||||
return theme.palette.info.main;
|
||||
case WiFiConnectionStatus.WIFI_STATUS_CONNECTED:
|
||||
return theme.palette.success.main;
|
||||
case WiFiConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
|
||||
case WiFiConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
|
||||
return theme.palette.error.main;
|
||||
default:
|
||||
return theme.palette.warning.main;
|
||||
}
|
||||
}
|
||||
|
||||
export const wifiStatus = ({ status }: WiFiStatus) => {
|
||||
switch (status) {
|
||||
case WiFiConnectionStatus.WIFI_STATUS_NO_SHIELD:
|
||||
return "Inactive";
|
||||
case WiFiConnectionStatus.WIFI_STATUS_IDLE:
|
||||
return "Idle";
|
||||
case WiFiConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
|
||||
return "No SSID Available";
|
||||
case WiFiConnectionStatus.WIFI_STATUS_CONNECTED:
|
||||
return "Connected";
|
||||
case WiFiConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
|
||||
return "Connection Failed";
|
||||
case WiFiConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
|
||||
return "Connection Lost";
|
||||
case WiFiConnectionStatus.WIFI_STATUS_DISCONNECTED:
|
||||
return "Disconnected";
|
||||
default:
|
||||
return "Unknown";
|
||||
}
|
||||
}
|
29
interface/src/wifi/WiFiStatusController.tsx
Normal file
29
interface/src/wifi/WiFiStatusController.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import {restController, RestControllerProps, RestFormLoader, SectionContent } from '../components';
|
||||
import WiFiStatusForm from './WiFiStatusForm';
|
||||
import { WIFI_STATUS_ENDPOINT } from '../api';
|
||||
import { WiFiStatus } from './types';
|
||||
|
||||
type WiFiStatusControllerProps = RestControllerProps<WiFiStatus>;
|
||||
|
||||
class WiFiStatusController extends Component<WiFiStatusControllerProps> {
|
||||
|
||||
componentDidMount() {
|
||||
this.props.loadData();
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<SectionContent title="WiFi Status">
|
||||
<RestFormLoader
|
||||
{...this.props}
|
||||
render={formProps => <WiFiStatusForm {...formProps} />}
|
||||
/>
|
||||
</SectionContent>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default restController(WIFI_STATUS_ENDPOINT, WiFiStatusController);
|
117
interface/src/wifi/WiFiStatusForm.tsx
Normal file
117
interface/src/wifi/WiFiStatusForm.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
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 DNSIcon from '@material-ui/icons/Dns';
|
||||
import WifiIcon from '@material-ui/icons/Wifi';
|
||||
import SettingsInputComponentIcon from '@material-ui/icons/SettingsInputComponent';
|
||||
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
|
||||
import DeviceHubIcon from '@material-ui/icons/DeviceHub';
|
||||
import RefreshIcon from '@material-ui/icons/Refresh';
|
||||
|
||||
import { RestFormProps, FormActions, FormButton, HighlightAvatar } from '../components';
|
||||
import { wifiStatus, wifiStatusHighlight, isConnected } from './WiFiStatus';
|
||||
import { WiFiStatus } from './types';
|
||||
|
||||
type WiFiStatusFormProps = RestFormProps<WiFiStatus> & WithTheme;
|
||||
|
||||
class WiFiStatusForm extends Component<WiFiStatusFormProps> {
|
||||
|
||||
dnsServers(status: WiFiStatus) {
|
||||
if (!status.dns_ip_1) {
|
||||
return "none";
|
||||
}
|
||||
return status.dns_ip_1 + (status.dns_ip_2 ? ',' + status.dns_ip_2 : '');
|
||||
}
|
||||
|
||||
createListItems() {
|
||||
const { data, theme } = this.props
|
||||
return (
|
||||
<Fragment>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<HighlightAvatar color={wifiStatusHighlight(data, theme)}>
|
||||
<WifiIcon />
|
||||
</HighlightAvatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="Status" secondary={wifiStatus(data)} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
{
|
||||
isConnected(data) &&
|
||||
<Fragment>
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>
|
||||
<SettingsInputAntennaIcon />
|
||||
</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="SSID" secondary={data.ssid} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
<ListItem>
|
||||
<ListItemAvatar>
|
||||
<Avatar>IP</Avatar>
|
||||
</ListItemAvatar>
|
||||
<ListItemText primary="IP Address" secondary={data.local_ip} />
|
||||
</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={this.dnsServers(data)} />
|
||||
</ListItem>
|
||||
<Divider variant="inset" component="li" />
|
||||
</Fragment>
|
||||
}
|
||||
</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(WiFiStatusForm);
|
56
interface/src/wifi/types.ts
Normal file
56
interface/src/wifi/types.ts
Normal file
@ -0,0 +1,56 @@
|
||||
export enum WiFiConnectionStatus {
|
||||
WIFI_STATUS_IDLE = 0,
|
||||
WIFI_STATUS_NO_SSID_AVAIL = 1,
|
||||
WIFI_STATUS_CONNECTED = 3,
|
||||
WIFI_STATUS_CONNECT_FAILED = 4,
|
||||
WIFI_STATUS_CONNECTION_LOST = 5,
|
||||
WIFI_STATUS_DISCONNECTED = 6,
|
||||
WIFI_STATUS_NO_SHIELD = 255
|
||||
}
|
||||
|
||||
export enum WiFiEncryptionType {
|
||||
WIFI_AUTH_OPEN = 0,
|
||||
WIFI_AUTH_WEP = 1,
|
||||
WIFI_AUTH_WEP_PSK = 2,
|
||||
WIFI_AUTH_WEP2_PSK = 3,
|
||||
WIFI_AUTH_WPA_WPA2_PSK = 4,
|
||||
WIFI_AUTH_WPA2_ENTERPRISE = 5
|
||||
}
|
||||
|
||||
export interface WiFiStatus {
|
||||
status: WiFiConnectionStatus;
|
||||
local_ip: string;
|
||||
mac_address: string;
|
||||
rssi: number;
|
||||
ssid: string;
|
||||
bssid: string;
|
||||
channel: number;
|
||||
subnet_mask: string;
|
||||
gateway_ip: string;
|
||||
dns_ip_1: string;
|
||||
dns_ip_2: string;
|
||||
}
|
||||
|
||||
export interface WiFiSettings {
|
||||
ssid: string;
|
||||
password: string;
|
||||
hostname: string;
|
||||
static_ip_config: boolean;
|
||||
local_ip?: string;
|
||||
gateway_ip?: string;
|
||||
subnet_mask?: string;
|
||||
dns_ip_1?: string;
|
||||
dns_ip_2?: string;
|
||||
}
|
||||
|
||||
export interface WiFiNetworkList {
|
||||
networks: WiFiNetwork[];
|
||||
}
|
||||
|
||||
export interface WiFiNetwork {
|
||||
rssi: number;
|
||||
ssid: string;
|
||||
bssid: string;
|
||||
channel: number;
|
||||
encryption_type: WiFiEncryptionType;
|
||||
}
|
Reference in New Issue
Block a user