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