This commit is contained in:
proddy
2021-05-14 12:45:57 +02:00
parent 15df0c0552
commit fec5ff3132
108 changed files with 3508 additions and 2455 deletions

View File

@@ -1,42 +1,42 @@
const ManifestPlugin = require('webpack-manifest-plugin') const ManifestPlugin = require('webpack-manifest-plugin');
const WorkboxWebpackPlugin = require('workbox-webpack-plugin') const WorkboxWebpackPlugin = require('workbox-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin') const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CompressionPlugin = require('compression-webpack-plugin') const CompressionPlugin = require('compression-webpack-plugin');
const ProgmemGenerator = require('./progmem-generator.js') const ProgmemGenerator = require('./progmem-generator.js');
module.exports = function override(config, env) { module.exports = function override(config, env) {
const hosted = process.env.REACT_APP_HOSTED const hosted = process.env.REACT_APP_HOSTED;
if (env === 'production' && !hosted) { if (env === 'production' && !hosted) {
console.log('Custom webpack...') console.log('Custom webpack...');
// rename the output file, we need it's path to be short for LittleFS // rename the output file, we need it's path to be short for LittleFS
config.output.filename = 'js/[id].[chunkhash:4].js' config.output.filename = 'js/[id].[chunkhash:4].js';
config.output.chunkFilename = 'js/[id].[chunkhash:4].js' config.output.chunkFilename = 'js/[id].[chunkhash:4].js';
// take out the manifest and service worker plugins // take out the manifest and service worker plugins
config.plugins = config.plugins.filter( config.plugins = config.plugins.filter(
(plugin) => !(plugin instanceof ManifestPlugin), (plugin) => !(plugin instanceof ManifestPlugin)
) );
config.plugins = config.plugins.filter( config.plugins = config.plugins.filter(
(plugin) => !(plugin instanceof WorkboxWebpackPlugin.GenerateSW), (plugin) => !(plugin instanceof WorkboxWebpackPlugin.GenerateSW)
) );
// shorten css filenames // shorten css filenames
const miniCssExtractPlugin = config.plugins.find( const miniCssExtractPlugin = config.plugins.find(
(plugin) => plugin instanceof MiniCssExtractPlugin, (plugin) => plugin instanceof MiniCssExtractPlugin
) );
miniCssExtractPlugin.options.filename = 'css/[id].[contenthash:4].css' miniCssExtractPlugin.options.filename = 'css/[id].[contenthash:4].css';
miniCssExtractPlugin.options.chunkFilename = miniCssExtractPlugin.options.chunkFilename =
'css/[id].[contenthash:4].c.css' 'css/[id].[contenthash:4].c.css';
// build progmem data files // build progmem data files
config.plugins.push( config.plugins.push(
new ProgmemGenerator({ new ProgmemGenerator({
outputPath: '../lib/framework/WWWData.h', outputPath: '../lib/framework/WWWData.h',
bytesPerLine: 20, bytesPerLine: 20
}), })
) );
// add compression plugin, compress javascript // add compression plugin, compress javascript
config.plugins.push( config.plugins.push(
@@ -44,9 +44,9 @@ module.exports = function override(config, env) {
filename: '[path].gz[query]', filename: '[path].gz[query]',
algorithm: 'gzip', algorithm: 'gzip',
test: /\.(js)$/, test: /\.(js)$/,
deleteOriginalAssets: true, deleteOriginalAssets: true
}), })
) );
} }
return config return config;
} };

View File

@@ -1,37 +1,37 @@
const { resolve, relative, sep } = require('path') const { resolve, relative, sep } = require('path');
const { const {
readdirSync, readdirSync,
existsSync, existsSync,
unlinkSync, unlinkSync,
readFileSync, readFileSync,
createWriteStream, createWriteStream
} = require('fs') } = require('fs');
var zlib = require('zlib') var zlib = require('zlib');
var mime = require('mime-types') var mime = require('mime-types');
const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n' const ARDUINO_INCLUDES = '#include <Arduino.h>\n\n';
function getFilesSync(dir, files = []) { function getFilesSync(dir, files = []) {
readdirSync(dir, { withFileTypes: true }).forEach((entry) => { readdirSync(dir, { withFileTypes: true }).forEach((entry) => {
const entryPath = resolve(dir, entry.name) const entryPath = resolve(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
getFilesSync(entryPath, files) getFilesSync(entryPath, files);
} else { } else {
files.push(entryPath) files.push(entryPath);
} }
}) });
return files return files;
} }
function coherseToBuffer(input) { function coherseToBuffer(input) {
return Buffer.isBuffer(input) ? input : Buffer.from(input) return Buffer.isBuffer(input) ? input : Buffer.from(input);
} }
function cleanAndOpen(path) { function cleanAndOpen(path) {
if (existsSync(path)) { if (existsSync(path)) {
unlinkSync(path) unlinkSync(path);
} }
return createWriteStream(path, { flags: 'w+' }) return createWriteStream(path, { flags: 'w+' });
} }
class ProgmemGenerator { class ProgmemGenerator {
@@ -40,70 +40,70 @@ class ProgmemGenerator {
outputPath, outputPath,
bytesPerLine = 20, bytesPerLine = 20,
indent = ' ', indent = ' ',
includes = ARDUINO_INCLUDES, includes = ARDUINO_INCLUDES
} = options } = options;
this.options = { outputPath, bytesPerLine, indent, includes } this.options = { outputPath, bytesPerLine, indent, includes };
} }
apply(compiler) { apply(compiler) {
compiler.hooks.emit.tapAsync( compiler.hooks.emit.tapAsync(
{ name: 'ProgmemGenerator' }, { name: 'ProgmemGenerator' },
(compilation, callback) => { (compilation, callback) => {
const { outputPath, bytesPerLine, indent, includes } = this.options const { outputPath, bytesPerLine, indent, includes } = this.options;
const fileInfo = [] const fileInfo = [];
const writeStream = cleanAndOpen( const writeStream = cleanAndOpen(
resolve(compilation.options.context, outputPath), resolve(compilation.options.context, outputPath)
) );
try { try {
const writeIncludes = () => { const writeIncludes = () => {
writeStream.write(includes) writeStream.write(includes);
} };
const writeFile = (relativeFilePath, buffer) => { const writeFile = (relativeFilePath, buffer) => {
const variable = 'ESP_REACT_DATA_' + fileInfo.length const variable = 'ESP_REACT_DATA_' + fileInfo.length;
const mimeType = mime.lookup(relativeFilePath) const mimeType = mime.lookup(relativeFilePath);
var size = 0 var size = 0;
writeStream.write('const uint8_t ' + variable + '[] PROGMEM = {') writeStream.write('const uint8_t ' + variable + '[] PROGMEM = {');
const zipBuffer = zlib.gzipSync(buffer) const zipBuffer = zlib.gzipSync(buffer);
zipBuffer.forEach((b) => { zipBuffer.forEach((b) => {
if (!(size % bytesPerLine)) { if (!(size % bytesPerLine)) {
writeStream.write('\n') writeStream.write('\n');
writeStream.write(indent) writeStream.write(indent);
} }
writeStream.write( writeStream.write(
'0x' + ('00' + b.toString(16).toUpperCase()).substr(-2) + ',', '0x' + ('00' + b.toString(16).toUpperCase()).substr(-2) + ','
) );
size++ size++;
}) });
if (size % bytesPerLine) { if (size % bytesPerLine) {
writeStream.write('\n') writeStream.write('\n');
} }
writeStream.write('};\n\n') writeStream.write('};\n\n');
fileInfo.push({ fileInfo.push({
uri: '/' + relativeFilePath.replace(sep, '/'), uri: '/' + relativeFilePath.replace(sep, '/'),
mimeType, mimeType,
variable, variable,
size, size
}) });
} };
const writeFiles = () => { const writeFiles = () => {
// process static files // process static files
const buildPath = compilation.options.output.path const buildPath = compilation.options.output.path;
for (const filePath of getFilesSync(buildPath)) { for (const filePath of getFilesSync(buildPath)) {
const readStream = readFileSync(filePath) const readStream = readFileSync(filePath);
const relativeFilePath = relative(buildPath, filePath) const relativeFilePath = relative(buildPath, filePath);
writeFile(relativeFilePath, readStream) writeFile(relativeFilePath, readStream);
} }
// process assets // process assets
const { assets } = compilation const { assets } = compilation;
Object.keys(assets).forEach((relativeFilePath) => { Object.keys(assets).forEach((relativeFilePath) => {
writeFile( writeFile(
relativeFilePath, relativeFilePath,
coherseToBuffer(assets[relativeFilePath].source()), coherseToBuffer(assets[relativeFilePath].source())
) );
}) });
} };
const generateWWWClass = () => { const generateWWWClass = () => {
return `typedef std::function<void(const String& uri, const String& contentType, const uint8_t * content, size_t len)> RouteRegistrationHandler; return `typedef std::function<void(const String& uri, const String& contentType, const uint8_t * content, size_t len)> RouteRegistrationHandler;
@@ -111,38 +111,38 @@ class ProgmemGenerator {
class WWWData { class WWWData {
${indent}public: ${indent}public:
${indent.repeat( ${indent.repeat(
2, 2
)}static void registerRoutes(RouteRegistrationHandler handler) { )}static void registerRoutes(RouteRegistrationHandler handler) {
${fileInfo ${fileInfo
.map( .map(
(file) => (file) =>
`${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${ `${indent.repeat(3)}handler("${file.uri}", "${file.mimeType}", ${
file.variable file.variable
}, ${file.size});`, }, ${file.size});`
) )
.join('\n')} .join('\n')}
${indent.repeat(2)}} ${indent.repeat(2)}}
}; };
` `;
} };
const writeWWWClass = () => { const writeWWWClass = () => {
writeStream.write(generateWWWClass()) writeStream.write(generateWWWClass());
} };
writeIncludes() writeIncludes();
writeFiles() writeFiles();
writeWWWClass() writeWWWClass();
writeStream.on('finish', () => { writeStream.on('finish', () => {
callback() callback();
}) });
} finally { } finally {
writeStream.end() writeStream.end();
} }
}, }
) );
} }
} }
module.exports = ProgmemGenerator module.exports = ProgmemGenerator;

View File

@@ -14,7 +14,6 @@ import FeaturesWrapper from './features/FeaturesWrapper';
const unauthorizedRedirect = () => <Redirect to="/" />; const unauthorizedRedirect = () => <Redirect to="/" />;
class App extends Component { class App extends Component {
notistackRef: RefObject<any> = React.createRef(); notistackRef: RefObject<any> = React.createRef();
componentDidMount() { componentDidMount() {
@@ -23,21 +22,29 @@ class App extends Component {
onClickDismiss = (key: string | number | undefined) => () => { onClickDismiss = (key: string | number | undefined) => () => {
this.notistackRef.current.closeSnackbar(key); this.notistackRef.current.closeSnackbar(key);
} };
render() { render() {
return ( return (
<CustomMuiTheme> <CustomMuiTheme>
<SnackbarProvider autoHideDuration={3000} maxSnack={3} anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }} <SnackbarProvider
autoHideDuration={3000}
maxSnack={3}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
ref={this.notistackRef} ref={this.notistackRef}
action={(key) => ( action={(key) => (
<IconButton onClick={this.onClickDismiss(key)} size="small"> <IconButton onClick={this.onClickDismiss(key)} size="small">
<CloseIcon /> <CloseIcon />
</IconButton> </IconButton>
)}> )}
>
<FeaturesWrapper> <FeaturesWrapper>
<Switch> <Switch>
<Route exact path="/unauthorized" component={unauthorizedRedirect} /> <Route
exact
path="/unauthorized"
component={unauthorizedRedirect}
/>
<Route component={AppRouting} /> <Route component={AppRouting} />
</Switch> </Switch>
</FeaturesWrapper> </FeaturesWrapper>
@@ -47,4 +54,4 @@ class App extends Component {
} }
} }
export default App export default App;

View File

@@ -19,9 +19,9 @@ import Mqtt from './mqtt/Mqtt';
import { withFeatures, WithFeaturesProps } from './features/FeaturesContext'; import { withFeatures, WithFeaturesProps } from './features/FeaturesContext';
import { Features } from './features/types'; import { Features } from './features/types';
export const getDefaultRoute = (features: Features) => features.project ? `/${PROJECT_PATH}/` : "/network/"; export const getDefaultRoute = (features: Features) =>
features.project ? `/${PROJECT_PATH}/` : '/network/';
class AppRouting extends Component<WithFeaturesProps> { class AppRouting extends Component<WithFeaturesProps> {
componentDidMount() { componentDidMount() {
Authentication.clearLoginRedirect(); Authentication.clearLoginRedirect();
} }
@@ -35,9 +35,17 @@ class AppRouting extends Component<WithFeaturesProps> {
<UnauthenticatedRoute exact path="/" component={SignIn} /> <UnauthenticatedRoute exact path="/" component={SignIn} />
)} )}
{features.project && ( {features.project && (
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/*`} component={ProjectRouting} /> <AuthenticatedRoute
exact
path={`/${PROJECT_PATH}/*`}
component={ProjectRouting}
/>
)} )}
<AuthenticatedRoute exact path="/network/*" component={NetworkConnection} /> <AuthenticatedRoute
exact
path="/network/*"
component={NetworkConnection}
/>
<AuthenticatedRoute exact path="/ap/*" component={AccessPoint} /> <AuthenticatedRoute exact path="/ap/*" component={AccessPoint} />
{features.ntp && ( {features.ntp && (
<AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} /> <AuthenticatedRoute exact path="/ntp/*" component={NetworkTime} />
@@ -52,7 +60,7 @@ class AppRouting extends Component<WithFeaturesProps> {
<Redirect to={getDefaultRoute(features)} /> <Redirect to={getDefaultRoute(features)} />
</Switch> </Switch>
</AuthenticationWrapper> </AuthenticationWrapper>
) );
} }
} }

View File

@@ -1,17 +1,21 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { CssBaseline } from '@material-ui/core'; import { CssBaseline } from '@material-ui/core';
import { MuiThemeProvider, createMuiTheme, StylesProvider } from '@material-ui/core/styles'; import {
MuiThemeProvider,
createMuiTheme,
StylesProvider
} from '@material-ui/core/styles';
import { blueGrey, orange, red, green } from '@material-ui/core/colors'; import { blueGrey, orange, red, green } from '@material-ui/core/colors';
const theme = createMuiTheme({ const theme = createMuiTheme({
palette: { palette: {
type: "dark", type: 'dark',
primary: { primary: {
main: '#33bfff', main: '#33bfff'
}, },
secondary: { secondary: {
main: '#3d5afe', main: '#3d5afe'
}, },
info: { info: {
main: blueGrey[500] main: blueGrey[500]
@@ -29,7 +33,6 @@ const theme = createMuiTheme({
}); });
export default class CustomMuiTheme extends Component { export default class CustomMuiTheme extends Component {
render() { render() {
return ( return (
<StylesProvider> <StylesProvider>
@@ -40,5 +43,4 @@ export default class CustomMuiTheme extends Component {
</StylesProvider> </StylesProvider>
); );
} }
} }

View File

@@ -2,53 +2,63 @@ import React, { Component } from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack'; import { withSnackbar, WithSnackbarProps } from 'notistack';
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator'; import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import { withStyles, createStyles, Theme, WithStyles } from '@material-ui/core/styles'; import {
withStyles,
createStyles,
Theme,
WithStyles
} from '@material-ui/core/styles';
import { Paper, Typography, Fab } from '@material-ui/core'; import { Paper, Typography, Fab } from '@material-ui/core';
import ForwardIcon from '@material-ui/icons/Forward'; import ForwardIcon from '@material-ui/icons/Forward';
import { withAuthenticationContext, AuthenticationContextProps } from './authentication/AuthenticationContext'; import {
withAuthenticationContext,
AuthenticationContextProps
} from './authentication/AuthenticationContext';
import { PasswordValidator } from './components'; import { PasswordValidator } from './components';
import { PROJECT_NAME, SIGN_IN_ENDPOINT } from './api'; import { PROJECT_NAME, SIGN_IN_ENDPOINT } from './api';
const styles = (theme: Theme) => createStyles({ const styles = (theme: Theme) =>
signInPage: { createStyles({
display: "flex", signInPage: {
height: "100vh", display: 'flex',
margin: "auto", height: '100vh',
padding: theme.spacing(2), margin: 'auto',
justifyContent: "center", padding: theme.spacing(2),
flexDirection: "column", justifyContent: 'center',
maxWidth: theme.breakpoints.values.sm flexDirection: 'column',
}, maxWidth: theme.breakpoints.values.sm
signInPanel: { },
textAlign: "center", signInPanel: {
padding: theme.spacing(2), textAlign: 'center',
paddingTop: "200px", padding: theme.spacing(2),
backgroundImage: 'url("/app/icon.png")', paddingTop: '200px',
backgroundRepeat: "no-repeat", backgroundImage: 'url("/app/icon.png")',
backgroundPosition: "50% " + theme.spacing(2) + "px", backgroundRepeat: 'no-repeat',
backgroundSize: "auto 150px", backgroundPosition: '50% ' + theme.spacing(2) + 'px',
width: "100%" backgroundSize: 'auto 150px',
}, width: '100%'
extendedIcon: { },
marginRight: theme.spacing(0.5), extendedIcon: {
}, marginRight: theme.spacing(0.5)
button: { },
marginRight: theme.spacing(2), button: {
marginTop: theme.spacing(2), marginRight: theme.spacing(2),
} marginTop: theme.spacing(2)
}); }
});
type SignInProps = WithSnackbarProps & WithStyles<typeof styles> & AuthenticationContextProps; type SignInProps = WithSnackbarProps &
WithStyles<typeof styles> &
AuthenticationContextProps;
interface SignInState { interface SignInState {
username: string, username: string;
password: string, password: string;
processing: boolean processing: boolean;
} }
class SignIn extends Component<SignInProps, SignInState> { class SignIn extends Component<SignInProps, SignInState> {
constructor(props: SignInProps) { constructor(props: SignInProps) {
super(props); super(props);
this.state = { this.state = {
@@ -60,10 +70,10 @@ class SignIn extends Component<SignInProps, SignInState> {
updateInputElement = (event: React.ChangeEvent<HTMLInputElement>): void => { updateInputElement = (event: React.ChangeEvent<HTMLInputElement>): void => {
const { name, value } = event.currentTarget; const { name, value } = event.currentTarget;
this.setState(prevState => ({ this.setState((prevState) => ({
...prevState, ...prevState,
[name]: value, [name]: value
})) }));
}; };
onSubmit = () => { onSubmit = () => {
@@ -77,20 +87,21 @@ class SignIn extends Component<SignInProps, SignInState> {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
}) })
}) })
.then(response => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
return response.json(); return response.json();
} else if (response.status === 401) { } else if (response.status === 401) {
throw Error("Invalid credentials."); throw Error('Invalid credentials.');
} else { } else {
throw Error("Invalid status code: " + response.status); throw Error('Invalid status code: ' + response.status);
} }
}).then(json => { })
.then((json) => {
authenticationContext.signIn(json.access_token); authenticationContext.signIn(json.access_token);
}) })
.catch(error => { .catch((error) => {
this.props.enqueueSnackbar(error.message, { this.props.enqueueSnackbar(error.message, {
variant: 'warning', variant: 'warning'
}); });
this.setState({ processing: false }); this.setState({ processing: false });
}); });
@@ -116,8 +127,8 @@ class SignIn extends Component<SignInProps, SignInState> {
onChange={this.updateInputElement} onChange={this.updateInputElement}
margin="normal" margin="normal"
inputProps={{ inputProps={{
autoCapitalize: "none", autoCapitalize: 'none',
autoCorrect: "off", autoCorrect: 'off'
}} }}
/> />
<PasswordValidator <PasswordValidator
@@ -132,7 +143,13 @@ class SignIn extends Component<SignInProps, SignInState> {
onChange={this.updateInputElement} onChange={this.updateInputElement}
margin="normal" margin="normal"
/> />
<Fab variant="extended" color="primary" className={classes.button} type="submit" disabled={processing}> <Fab
variant="extended"
color="primary"
className={classes.button}
type="submit"
disabled={processing}
>
<ForwardIcon className={classes.extendedIcon} /> <ForwardIcon className={classes.extendedIcon} />
Sign In Sign In
</Fab> </Fab>
@@ -141,7 +158,8 @@ class SignIn extends Component<SignInProps, SignInState> {
</div> </div>
); );
} }
} }
export default withAuthenticationContext(withSnackbar(withStyles(styles)(SignIn))); export default withAuthenticationContext(
withSnackbar(withStyles(styles)(SignIn))
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { AP_STATUS_ENDPOINT } from '../api'; import { AP_STATUS_ENDPOINT } from '../api';
import APStatusForm from './APStatusForm'; import APStatusForm from './APStatusForm';
@@ -9,7 +14,6 @@ import { APStatus } from './types';
type APStatusControllerProps = RestControllerProps<APStatus>; type APStatusControllerProps = RestControllerProps<APStatus>;
class APStatusController extends Component<APStatusControllerProps> { class APStatusController extends Component<APStatusControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -19,10 +23,10 @@ class APStatusController extends Component<APStatusControllerProps> {
<SectionContent title="Access Point Status"> <SectionContent title="Access Point Status">
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <APStatusForm {...formProps} />} render={(formProps) => <APStatusForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
) );
} }
} }

View File

@@ -1,23 +1,34 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { WithTheme, withTheme } from '@material-ui/core/styles'; import { WithTheme, withTheme } from '@material-ui/core/styles';
import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core'; import {
Avatar,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText
} from '@material-ui/core';
import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
import DeviceHubIcon from '@material-ui/icons/DeviceHub'; import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import ComputerIcon from '@material-ui/icons/Computer'; import ComputerIcon from '@material-ui/icons/Computer';
import RefreshIcon from '@material-ui/icons/Refresh'; import RefreshIcon from '@material-ui/icons/Refresh';
import { RestFormProps, FormActions, FormButton, HighlightAvatar } from '../components'; import {
RestFormProps,
FormActions,
FormButton,
HighlightAvatar
} from '../components';
import { apStatusHighlight, apStatus } from './APStatus'; import { apStatusHighlight, apStatus } from './APStatus';
import { APStatus } from './types'; import { APStatus } from './types';
type APStatusFormProps = RestFormProps<APStatus> & WithTheme; type APStatusFormProps = RestFormProps<APStatus> & WithTheme;
class APStatusForm extends Component<APStatusFormProps> { class APStatusForm extends Component<APStatusFormProps> {
createListItems() { createListItems() {
const { data, theme } = this.props const { data, theme } = this.props;
return ( return (
<Fragment> <Fragment>
<ListItem> <ListItem>
@@ -61,18 +72,20 @@ class APStatusForm extends Component<APStatusFormProps> {
render() { render() {
return ( return (
<Fragment> <Fragment>
<List> <List>{this.createListItems()}</List>
{this.createListItems()}
</List>
<FormActions> <FormActions>
<FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}> <FormButton
startIcon={<RefreshIcon />}
variant="contained"
color="secondary"
onClick={this.props.loadData}
>
Refresh Refresh
</FormButton> </FormButton>
</FormActions> </FormActions>
</Fragment> </Fragment>
); );
} }
} }
export default withTheme(APStatusForm); export default withTheme(APStatusForm);

View File

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

View File

@@ -1,27 +1,27 @@
export enum APProvisionMode { export enum APProvisionMode {
AP_MODE_ALWAYS = 0, AP_MODE_ALWAYS = 0,
AP_MODE_DISCONNECTED = 1, AP_MODE_DISCONNECTED = 1,
AP_NEVER = 2, AP_NEVER = 2
} }
export enum APNetworkStatus { export enum APNetworkStatus {
ACTIVE = 0, ACTIVE = 0,
INACTIVE = 1, INACTIVE = 1,
LINGERING = 2, LINGERING = 2
} }
export interface APStatus { export interface APStatus {
status: APNetworkStatus status: APNetworkStatus;
ip_address: string ip_address: string;
mac_address: string mac_address: string;
station_num: number station_num: number;
} }
export interface APSettings { export interface APSettings {
provision_mode: APProvisionMode provision_mode: APProvisionMode;
ssid: string ssid: string;
password: string password: string;
local_ip: string local_ip: string;
gateway_ip: string gateway_ip: string;
subnet_mask: string subnet_mask: string;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,42 +1,42 @@
import * as H from 'history' import * as H from 'history';
import history from '../history' import history from '../history';
import { Features } from '../features/types' import { Features } from '../features/types';
import { getDefaultRoute } from '../AppRouting' import { getDefaultRoute } from '../AppRouting';
export const ACCESS_TOKEN = 'access_token' export const ACCESS_TOKEN = 'access_token';
export const SIGN_IN_PATHNAME = 'signInPathname' export const SIGN_IN_PATHNAME = 'signInPathname';
export const SIGN_IN_SEARCH = 'signInSearch' export const SIGN_IN_SEARCH = 'signInSearch';
/** /**
* Fallback to sessionStorage if localStorage is absent. WebView may not have local storage enabled. * Fallback to sessionStorage if localStorage is absent. WebView may not have local storage enabled.
*/ */
export function getStorage() { export function getStorage() {
return localStorage || sessionStorage return localStorage || sessionStorage;
} }
export function storeLoginRedirect(location?: H.Location) { export function storeLoginRedirect(location?: H.Location) {
if (location) { if (location) {
getStorage().setItem(SIGN_IN_PATHNAME, location.pathname) getStorage().setItem(SIGN_IN_PATHNAME, location.pathname);
getStorage().setItem(SIGN_IN_SEARCH, location.search) getStorage().setItem(SIGN_IN_SEARCH, location.search);
} }
} }
export function clearLoginRedirect() { export function clearLoginRedirect() {
getStorage().removeItem(SIGN_IN_PATHNAME) getStorage().removeItem(SIGN_IN_PATHNAME);
getStorage().removeItem(SIGN_IN_SEARCH) getStorage().removeItem(SIGN_IN_SEARCH);
} }
export function fetchLoginRedirect( export function fetchLoginRedirect(
features: Features, features: Features
): H.LocationDescriptorObject { ): H.LocationDescriptorObject {
const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME) const signInPathname = getStorage().getItem(SIGN_IN_PATHNAME);
const signInSearch = getStorage().getItem(SIGN_IN_SEARCH) const signInSearch = getStorage().getItem(SIGN_IN_SEARCH);
clearLoginRedirect() clearLoginRedirect();
return { return {
pathname: signInPathname || getDefaultRoute(features), pathname: signInPathname || getDefaultRoute(features),
search: (signInPathname && signInSearch) || undefined, search: (signInPathname && signInSearch) || undefined
} };
} }
/** /**
@@ -44,18 +44,18 @@ export function fetchLoginRedirect(
*/ */
export function authorizedFetch( export function authorizedFetch(
url: RequestInfo, url: RequestInfo,
params?: RequestInit, params?: RequestInit
): Promise<Response> { ): Promise<Response> {
const accessToken = getStorage().getItem(ACCESS_TOKEN) const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
params = params || {} params = params || {};
params.credentials = 'include' params.credentials = 'include';
params.headers = { params.headers = {
...params.headers, ...params.headers,
Authorization: 'Bearer ' + accessToken, Authorization: 'Bearer ' + accessToken
} };
} }
return fetch(url, params) return fetch(url, params);
} }
/** /**
@@ -67,33 +67,33 @@ export function redirectingAuthorizedUpload(
xhr: XMLHttpRequest, xhr: XMLHttpRequest,
url: string, url: string,
file: File, file: File,
onProgress: (event: ProgressEvent<EventTarget>) => void, onProgress: (event: ProgressEvent<EventTarget>) => void
): Promise<void> { ): Promise<void> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
xhr.open('POST', url, true) xhr.open('POST', url, true);
const accessToken = getStorage().getItem(ACCESS_TOKEN) const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
xhr.withCredentials = true xhr.withCredentials = true;
xhr.setRequestHeader('Authorization', 'Bearer ' + accessToken) xhr.setRequestHeader('Authorization', 'Bearer ' + accessToken);
} }
xhr.upload.onprogress = onProgress xhr.upload.onprogress = onProgress;
xhr.onload = function () { xhr.onload = function () {
if (xhr.status === 401 || xhr.status === 403) { if (xhr.status === 401 || xhr.status === 403) {
history.push('/unauthorized') history.push('/unauthorized');
} else { } else {
resolve() resolve();
} }
} };
xhr.onerror = function (event: ProgressEvent<EventTarget>) { xhr.onerror = function () {
reject(new DOMException('Error', 'UploadError')) reject(new DOMException('Error', 'UploadError'));
} };
xhr.onabort = function () { xhr.onabort = function () {
reject(new DOMException('Aborted', 'AbortError')) reject(new DOMException('Aborted', 'AbortError'));
} };
const formData = new FormData() const formData = new FormData();
formData.append('file', file) formData.append('file', file);
xhr.send(formData) xhr.send(formData);
}) });
} }
/** /**
@@ -101,29 +101,29 @@ export function redirectingAuthorizedUpload(
*/ */
export function redirectingAuthorizedFetch( export function redirectingAuthorizedFetch(
url: RequestInfo, url: RequestInfo,
params?: RequestInit, params?: RequestInit
): Promise<Response> { ): Promise<Response> {
return new Promise<Response>((resolve, reject) => { return new Promise<Response>((resolve, reject) => {
authorizedFetch(url, params) authorizedFetch(url, params)
.then((response) => { .then((response) => {
if (response.status === 401 || response.status === 403) { if (response.status === 401 || response.status === 403) {
history.push('/unauthorized') history.push('/unauthorized');
} else { } else {
resolve(response) resolve(response);
} }
}) })
.catch((error) => { .catch((error) => {
reject(error) reject(error);
}) });
}) });
} }
export function addAccessTokenParameter(url: string) { export function addAccessTokenParameter(url: string) {
const accessToken = getStorage().getItem(ACCESS_TOKEN) const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (!accessToken) { if (!accessToken) {
return url return url;
} }
const parsedUrl = new URL(url) const parsedUrl = new URL(url);
parsedUrl.searchParams.set(ACCESS_TOKEN, accessToken) parsedUrl.searchParams.set(ACCESS_TOKEN, accessToken);
return parsedUrl.toString() return parsedUrl.toString();
} }

View File

@@ -1,9 +1,8 @@
import * as React from "react"; import * as React from 'react';
export interface Me { export interface Me {
username: string; username: string;
admin: boolean; admin: boolean;
version: string; // proddy added
} }
export interface AuthenticationContextValue { export interface AuthenticationContextValue {
@@ -13,7 +12,7 @@ export interface AuthenticationContextValue {
me?: Me; me?: Me;
} }
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
export const AuthenticationContext = React.createContext( export const AuthenticationContext = React.createContext(
AuthenticationContextDefaultValue AuthenticationContextDefaultValue
); );
@@ -22,12 +21,21 @@ export interface AuthenticationContextProps {
authenticationContext: AuthenticationContextValue; authenticationContext: AuthenticationContextValue;
} }
export function withAuthenticationContext<T extends AuthenticationContextProps>(Component: React.ComponentType<T>) { export function withAuthenticationContext<T extends AuthenticationContextProps>(
return class extends React.Component<Omit<T, keyof AuthenticationContextProps>> { Component: React.ComponentType<T>
) {
return class extends React.Component<
Omit<T, keyof AuthenticationContextProps>
> {
render() { render() {
return ( return (
<AuthenticationContext.Consumer> <AuthenticationContext.Consumer>
{authenticationContext => <Component {...this.props as T} authenticationContext={authenticationContext} />} {(authenticationContext) => (
<Component
{...(this.props as T)}
authenticationContext={authenticationContext}
/>
)}
</AuthenticationContext.Consumer> </AuthenticationContext.Consumer>
); );
} }
@@ -38,7 +46,7 @@ export interface AuthenticatedContextValue extends AuthenticationContextValue {
me: Me; me: Me;
} }
const AuthenticatedContextDefaultValue = {} as AuthenticatedContextValue const AuthenticatedContextDefaultValue = {} as AuthenticatedContextValue;
export const AuthenticatedContext = React.createContext( export const AuthenticatedContext = React.createContext(
AuthenticatedContextDefaultValue AuthenticatedContextDefaultValue
); );
@@ -47,12 +55,21 @@ export interface AuthenticatedContextProps {
authenticatedContext: AuthenticatedContextValue; authenticatedContext: AuthenticatedContextValue;
} }
export function withAuthenticatedContext<T extends AuthenticatedContextProps>(Component: React.ComponentType<T>) { export function withAuthenticatedContext<T extends AuthenticatedContextProps>(
return class extends React.Component<Omit<T, keyof AuthenticatedContextProps>> { Component: React.ComponentType<T>
) {
return class extends React.Component<
Omit<T, keyof AuthenticatedContextProps>
> {
render() { render() {
return ( return (
<AuthenticatedContext.Consumer> <AuthenticatedContext.Consumer>
{authenticatedContext => <Component {...this.props as T} authenticatedContext={authenticatedContext} />} {(authenticatedContext) => (
<Component
{...(this.props as T)}
authenticatedContext={authenticatedContext}
/>
)}
</AuthenticatedContext.Consumer> </AuthenticatedContext.Consumer>
); );
} }

View File

@@ -2,14 +2,19 @@ import * as React from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack'; import { withSnackbar, WithSnackbarProps } from 'notistack';
import jwtDecode from 'jwt-decode'; import jwtDecode from 'jwt-decode';
import history from '../history' import history from '../history';
import { VERIFY_AUTHORIZATION_ENDPOINT } from '../api'; import { VERIFY_AUTHORIZATION_ENDPOINT } from '../api';
import { ACCESS_TOKEN, authorizedFetch, getStorage } from './Authentication'; import { ACCESS_TOKEN, authorizedFetch, getStorage } from './Authentication';
import { AuthenticationContext, AuthenticationContextValue, Me } from './AuthenticationContext'; import {
AuthenticationContext,
AuthenticationContextValue,
Me
} from './AuthenticationContext';
import FullScreenLoading from '../components/FullScreenLoading'; import FullScreenLoading from '../components/FullScreenLoading';
import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext'; import { withFeatures, WithFeaturesProps } from '../features/FeaturesContext';
export const decodeMeJWT = (accessToken: string): Me => jwtDecode(accessToken) as Me; export const decodeMeJWT = (accessToken: string): Me =>
jwtDecode(accessToken) as Me;
interface AuthenticationWrapperState { interface AuthenticationWrapperState {
context: AuthenticationContextValue; context: AuthenticationContextValue;
@@ -18,15 +23,17 @@ interface AuthenticationWrapperState {
type AuthenticationWrapperProps = WithSnackbarProps & WithFeaturesProps; type AuthenticationWrapperProps = WithSnackbarProps & WithFeaturesProps;
class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps, AuthenticationWrapperState> { class AuthenticationWrapper extends React.Component<
AuthenticationWrapperProps,
AuthenticationWrapperState
> {
constructor(props: AuthenticationWrapperProps) { constructor(props: AuthenticationWrapperProps) {
super(props); super(props);
this.state = { this.state = {
context: { context: {
refresh: this.refresh, refresh: this.refresh,
signIn: this.signIn, signIn: this.signIn,
signOut: this.signOut, signOut: this.signOut
}, },
initialized: false initialized: false
}; };
@@ -39,7 +46,9 @@ class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps,
render() { render() {
return ( return (
<React.Fragment> <React.Fragment>
{this.state.initialized ? this.renderContent() : this.renderContentLoading()} {this.state.initialized
? this.renderContent()
: this.renderContentLoading()}
</React.Fragment> </React.Fragment>
); );
} }
@@ -53,9 +62,7 @@ class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps,
} }
renderContentLoading() { renderContentLoading() {
return ( return <FullScreenLoading />;
<FullScreenLoading />
);
} }
refresh = () => { refresh = () => {
@@ -64,34 +71,53 @@ class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps,
// this.setState({ initialized: true, context: { ...this.state.context, me: { admin: true, username: "admin" } } }); // this.setState({ initialized: true, context: { ...this.state.context, me: { admin: true, username: "admin" } } });
// return; // return;
// } // }
const accessToken = getStorage().getItem(ACCESS_TOKEN) const accessToken = getStorage().getItem(ACCESS_TOKEN);
if (accessToken) { if (accessToken) {
authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT) authorizedFetch(VERIFY_AUTHORIZATION_ENDPOINT)
.then(response => { .then((response) => {
const me = response.status === 200 ? decodeMeJWT(accessToken) : undefined; const me =
this.setState({ initialized: true, context: { ...this.state.context, me } }); response.status === 200 ? decodeMeJWT(accessToken) : undefined;
}).catch(error => { this.setState({
this.setState({ initialized: true, context: { ...this.state.context, me: undefined } }); initialized: true,
this.props.enqueueSnackbar("Error verifying authorization: " + error.message, { context: { ...this.state.context, me }
variant: 'error',
}); });
})
.catch((error) => {
this.setState({
initialized: true,
context: { ...this.state.context, me: undefined }
});
this.props.enqueueSnackbar(
'Error verifying authorization: ' + error.message,
{
variant: 'error'
}
);
}); });
} else { } else {
this.setState({ initialized: true, context: { ...this.state.context, me: undefined } }); this.setState({
initialized: true,
context: { ...this.state.context, me: undefined }
});
} }
} };
signIn = (accessToken: string) => { signIn = (accessToken: string) => {
try { try {
getStorage().setItem(ACCESS_TOKEN, accessToken); getStorage().setItem(ACCESS_TOKEN, accessToken);
const me: Me = decodeMeJWT(accessToken); const me: Me = decodeMeJWT(accessToken);
this.setState({ context: { ...this.state.context, me } }); this.setState({ context: { ...this.state.context, me } });
this.props.enqueueSnackbar(`Logged in as ${me.username}`, { variant: 'success' }); this.props.enqueueSnackbar(`Logged in as ${me.username}`, {
variant: 'success'
});
} catch (err) { } catch (err) {
this.setState({ initialized: true, context: { ...this.state.context, me: undefined } }); this.setState({
throw new Error("Failed to parse JWT " + err.message); initialized: true,
context: { ...this.state.context, me: undefined }
});
throw new Error('Failed to parse JWT ' + err.message);
} }
} };
signOut = () => { signOut = () => {
getStorage().removeItem(ACCESS_TOKEN); getStorage().removeItem(ACCESS_TOKEN);
@@ -101,10 +127,9 @@ class AuthenticationWrapper extends React.Component<AuthenticationWrapperProps,
me: undefined me: undefined
} }
}); });
this.props.enqueueSnackbar("You have signed out", { variant: 'success', }); this.props.enqueueSnackbar('You have signed out', { variant: 'success' });
history.push('/'); history.push('/');
} };
} }
export default withFeatures(withSnackbar(AuthenticationWrapper)) export default withFeatures(withSnackbar(AuthenticationWrapper));

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import React, { FC } from "react"; import { FC } from 'react';
import { FormControlLabel, FormControlLabelProps } from "@material-ui/core"; import { FormControlLabel, FormControlLabelProps } from '@material-ui/core';
const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => ( const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
<div> <div>
<FormControlLabel {...props} /> <FormControlLabel {...props} />
</div> </div>
) );
export default BlockFormControlLabel; export default BlockFormControlLabel;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,9 @@ import { withSnackbar, WithSnackbarProps } from 'notistack';
import { redirectingAuthorizedFetch } from '../authentication'; import { redirectingAuthorizedFetch } from '../authentication';
export interface RestControllerProps<D> extends WithSnackbarProps { export interface RestControllerProps<D> extends WithSnackbarProps {
handleValueChange: (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => void; handleValueChange: (
name: keyof D
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
setData: (data: D, callback?: () => void) => void; setData: (data: D, callback?: () => void) => void;
saveData: () => void; saveData: () => void;
@@ -15,16 +17,18 @@ export interface RestControllerProps<D> extends WithSnackbarProps {
errorMessage?: string; errorMessage?: string;
} }
export const extractEventValue = (event: React.ChangeEvent<HTMLInputElement>) => { export const extractEventValue = (
event: React.ChangeEvent<HTMLInputElement>
) => {
switch (event.target.type) { switch (event.target.type) {
case "number": case 'number':
return event.target.valueAsNumber; return event.target.valueAsNumber;
case "checkbox": case 'checkbox':
return event.target.checked; return event.target.checked;
default: default:
return event.target.value return event.target.value;
} }
} };
interface RestControllerState<D> { interface RestControllerState<D> {
data?: D; data?: D;
@@ -32,10 +36,15 @@ interface RestControllerState<D> {
errorMessage?: string; errorMessage?: string;
} }
export function restController<D, P extends RestControllerProps<D>>(endpointUrl: string, RestController: React.ComponentType<P & RestControllerProps<D>>) { export function restController<D, P extends RestControllerProps<D>>(
endpointUrl: string,
RestController: React.ComponentType<P & RestControllerProps<D>>
) {
return withSnackbar( return withSnackbar(
class extends React.Component<Omit<P, keyof RestControllerProps<D>> & WithSnackbarProps, RestControllerState<D>> { class extends React.Component<
Omit<P, keyof RestControllerProps<D>> & WithSnackbarProps,
RestControllerState<D>
> {
state: RestControllerState<D> = { state: RestControllerState<D> = {
data: undefined, data: undefined,
loading: false, loading: false,
@@ -43,12 +52,15 @@ export function restController<D, P extends RestControllerProps<D>>(endpointUrl:
}; };
setData = (data: D, callback?: () => void) => { setData = (data: D, callback?: () => void) => {
this.setState({ this.setState(
data, {
loading: false, data,
errorMessage: undefined loading: false,
}, callback); errorMessage: undefined
} },
callback
);
};
loadData = () => { loadData = () => {
this.setState({ this.setState({
@@ -56,19 +68,24 @@ export function restController<D, P extends RestControllerProps<D>>(endpointUrl:
loading: true, loading: true,
errorMessage: undefined errorMessage: undefined
}); });
redirectingAuthorizedFetch(endpointUrl).then(response => { redirectingAuthorizedFetch(endpointUrl)
if (response.status === 200) { .then((response) => {
return response.json(); if (response.status === 200) {
} return response.json();
throw Error("Invalid status code: " + response.status); }
}).then(json => { throw Error('Invalid status code: ' + response.status);
this.setState({ data: json, loading: false }) })
}).catch(error => { .then((json) => {
const errorMessage = error.message || "Unknown error"; this.setState({ data: json, loading: false });
this.props.enqueueSnackbar("Problem fetching: " + errorMessage, { variant: 'error' }); })
this.setState({ data: undefined, loading: false, errorMessage }); .catch((error) => {
}); const errorMessage = error.message || 'Unknown error';
} this.props.enqueueSnackbar('Problem fetching: ' + errorMessage, {
variant: 'error'
});
this.setState({ data: undefined, loading: false, errorMessage });
});
};
saveData = () => { saveData = () => {
this.setState({ loading: true }); this.setState({ loading: true });
@@ -78,36 +95,47 @@ export function restController<D, P extends RestControllerProps<D>>(endpointUrl:
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json'
} }
}).then(response => { })
if (response.status === 200) { .then((response) => {
return response.json(); if (response.status === 200) {
} return response.json();
throw Error("Invalid status code: " + response.status); }
}).then(json => { throw Error('Invalid status code: ' + response.status);
this.props.enqueueSnackbar("Update successful.", { variant: 'success' }); })
this.setState({ data: json, loading: false }); .then((json) => {
}).catch(error => { this.props.enqueueSnackbar('Update successful.', {
const errorMessage = error.message || "Unknown error"; variant: 'success'
this.props.enqueueSnackbar("Problem updating: " + errorMessage, { variant: 'error' }); });
this.setState({ data: undefined, loading: false, errorMessage }); 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>) => { handleValueChange = (name: keyof D) => (
event: React.ChangeEvent<HTMLInputElement>
) => {
const data = { ...this.state.data!, [name]: extractEventValue(event) }; const data = { ...this.state.data!, [name]: extractEventValue(event) };
this.setState({ data }); this.setState({ data });
} };
render() { render() {
return <RestController return (
{...this.state} <RestController
{...this.props as P} {...this.state}
handleValueChange={this.handleValueChange} {...(this.props as P)}
setData={this.setData} handleValueChange={this.handleValueChange}
saveData={this.saveData} setData={this.setData}
loadData={this.loadData} saveData={this.saveData}
/>; loadData={this.loadData}
/>
);
} }
}
}); );
} }

View File

@@ -8,20 +8,23 @@ import { RestControllerProps } from '.';
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
loadingSettings: { loadingSettings: {
margin: theme.spacing(0.5), margin: theme.spacing(0.5)
}, },
loadingSettingsDetails: { loadingSettingsDetails: {
margin: theme.spacing(4), margin: theme.spacing(4),
textAlign: "center" textAlign: 'center'
}, },
button: { button: {
marginRight: theme.spacing(2), marginRight: theme.spacing(2),
marginTop: theme.spacing(2), marginTop: theme.spacing(2)
} }
}) })
); );
export type RestFormProps<D> = Omit<RestControllerProps<D>, "loading" | "errorMessage"> & { data: D }; export type RestFormProps<D> = Omit<
RestControllerProps<D>,
'loading' | 'errorMessage'
> & { data: D };
interface RestFormLoaderProps<D> extends RestControllerProps<D> { interface RestFormLoaderProps<D> extends RestControllerProps<D> {
render: (props: RestFormProps<D>) => JSX.Element; render: (props: RestFormProps<D>) => JSX.Element;
@@ -46,7 +49,12 @@ export default function RestFormLoader<D>(props: RestFormLoaderProps<D>) {
<Typography variant="h6" className={classes.loadingSettingsDetails}> <Typography variant="h6" className={classes.loadingSettingsDetails}>
{errorMessage} {errorMessage}
</Typography> </Typography>
<Button variant="contained" color="secondary" className={classes.button} onClick={loadData}> <Button
variant="contained"
color="secondary"
className={classes.button}
onClick={loadData}
>
Retry Retry
</Button> </Button>
</div> </div>

View File

@@ -7,7 +7,7 @@ const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
content: { content: {
padding: theme.spacing(2), padding: theme.spacing(2),
margin: theme.spacing(3), margin: theme.spacing(3)
} }
}) })
); );

View File

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

View File

@@ -7,7 +7,9 @@ import { addAccessTokenParameter } from '../authentication';
import { extractEventValue } from '.'; import { extractEventValue } from '.';
export interface WebSocketControllerProps<D> extends WithSnackbarProps { export interface WebSocketControllerProps<D> extends WithSnackbarProps {
handleValueChange: (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => void; handleValueChange: (
name: keyof D
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
setData: (data: D, callback?: () => void) => void; setData: (data: D, callback?: () => void) => void;
saveData: () => void; saveData: () => void;
@@ -25,8 +27,8 @@ interface WebSocketControllerState<D> {
} }
enum WebSocketMessageType { enum WebSocketMessageType {
ID = "id", ID = 'id',
PAYLOAD = "payload" PAYLOAD = 'payload'
} }
interface WebSocketIdMessage { interface WebSocketIdMessage {
@@ -40,21 +42,32 @@ interface WebSocketPayloadMessage<D> {
payload: D; payload: D;
} }
export type WebSocketMessage<D> = WebSocketIdMessage | WebSocketPayloadMessage<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>>) { export function webSocketController<D, P extends WebSocketControllerProps<D>>(
wsUrl: string,
wsThrottle: number,
WebSocketController: React.ComponentType<P & WebSocketControllerProps<D>>
) {
return withSnackbar( return withSnackbar(
class extends React.Component<Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps, WebSocketControllerState<D>> { class extends React.Component<
constructor(props: Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps) { Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps,
WebSocketControllerState<D>
> {
constructor(
props: Omit<P, keyof WebSocketControllerProps<D>> & WithSnackbarProps
) {
super(props); super(props);
this.state = { this.state = {
ws: new Sockette(addAccessTokenParameter(wsUrl), { ws: new Sockette(addAccessTokenParameter(wsUrl), {
onmessage: this.onMessage, onmessage: this.onMessage,
onopen: this.onOpen, onopen: this.onOpen,
onclose: this.onClose, onclose: this.onClose
}), }),
connected: false connected: false
} };
} }
componentWillUnmount() { componentWillUnmount() {
@@ -64,37 +77,42 @@ export function webSocketController<D, P extends WebSocketControllerProps<D>>(ws
onMessage = (event: MessageEvent) => { onMessage = (event: MessageEvent) => {
const rawData = event.data; const rawData = event.data;
if (typeof rawData === 'string' || rawData instanceof String) { if (typeof rawData === 'string' || rawData instanceof String) {
this.handleMessage(JSON.parse(rawData as string) as WebSocketMessage<D>); this.handleMessage(
JSON.parse(rawData as string) as WebSocketMessage<D>
);
} }
} };
handleMessage = (message: WebSocketMessage<D>) => { handleMessage = (message: WebSocketMessage<D>) => {
const { clientId, data } = this.state;
switch (message.type) { switch (message.type) {
case WebSocketMessageType.ID: case WebSocketMessageType.ID:
this.setState({ clientId: message.id }); this.setState({ clientId: message.id });
break; break;
case WebSocketMessageType.PAYLOAD: case WebSocketMessageType.PAYLOAD:
const { clientId, data } = this.state;
if (clientId && (!data || clientId !== message.origin_id)) { if (clientId && (!data || clientId !== message.origin_id)) {
this.setState( this.setState({ data: message.payload });
{ data: message.payload }
);
} }
break; break;
} }
} };
onOpen = () => { onOpen = () => {
this.setState({ connected: true }); this.setState({ connected: true });
} };
onClose = () => { onClose = () => {
this.setState({ connected: false, clientId: undefined, data: undefined }); this.setState({
} connected: false,
clientId: undefined,
data: undefined
});
};
setData = (data: D, callback?: () => void) => { setData = (data: D, callback?: () => void) => {
this.setState({ data }, callback); this.setState({ data }, callback);
} };
saveData = throttle(() => { saveData = throttle(() => {
const { ws, connected, data } = this.state; const { ws, connected, data } = this.state;
@@ -106,28 +124,35 @@ export function webSocketController<D, P extends WebSocketControllerProps<D>>(ws
saveDataAndClear = throttle(() => { saveDataAndClear = throttle(() => {
const { ws, connected, data } = this.state; const { ws, connected, data } = this.state;
if (connected) { if (connected) {
this.setState({ this.setState(
data: undefined {
}, () => ws.json(data)); data: undefined
},
() => ws.json(data)
);
} }
}, wsThrottle); }, wsThrottle);
handleValueChange = (name: keyof D) => (event: React.ChangeEvent<HTMLInputElement>) => { handleValueChange = (name: keyof D) => (
event: React.ChangeEvent<HTMLInputElement>
) => {
const data = { ...this.state.data!, [name]: extractEventValue(event) }; const data = { ...this.state.data!, [name]: extractEventValue(event) };
this.setState({ data }); this.setState({ data });
} };
render() { render() {
return <WebSocketController return (
{...this.props as P} <WebSocketController
handleValueChange={this.handleValueChange} {...(this.props as P)}
setData={this.setData} handleValueChange={this.handleValueChange}
saveData={this.saveData} setData={this.setData}
saveDataAndClear={this.saveDataAndClear} saveData={this.saveData}
connected={this.state.connected} saveDataAndClear={this.saveDataAndClear}
data={this.state.data} connected={this.state.connected}
/>; data={this.state.data}
/>
);
} }
}
}); );
} }

View File

@@ -1,5 +1,3 @@
import React from 'react';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles'; import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import { LinearProgress, Typography } from '@material-ui/core'; import { LinearProgress, Typography } from '@material-ui/core';
@@ -8,22 +6,27 @@ import { WebSocketControllerProps } from '.';
const useStyles = makeStyles((theme: Theme) => const useStyles = makeStyles((theme: Theme) =>
createStyles({ createStyles({
loadingSettings: { loadingSettings: {
margin: theme.spacing(0.5), margin: theme.spacing(0.5)
}, },
loadingSettingsDetails: { loadingSettingsDetails: {
margin: theme.spacing(4), margin: theme.spacing(4),
textAlign: "center" textAlign: 'center'
} }
}) })
); );
export type WebSocketFormProps<D> = Omit<WebSocketControllerProps<D>, "connected"> & { data: D }; export type WebSocketFormProps<D> = Omit<
WebSocketControllerProps<D>,
'connected'
> & { data: D };
interface WebSocketFormLoaderProps<D> extends WebSocketControllerProps<D> { interface WebSocketFormLoaderProps<D> extends WebSocketControllerProps<D> {
render: (props: WebSocketFormProps<D>) => JSX.Element; render: (props: WebSocketFormProps<D>) => JSX.Element;
} }
export default function WebSocketFormLoader<D>(props: WebSocketFormLoaderProps<D>) { export default function WebSocketFormLoader<D>(
props: WebSocketFormLoaderProps<D>
) {
const { connected, render, data, ...rest } = props; const { connected, render, data, ...rest } = props;
const classes = useStyles(); const classes = useStyles();
if (!connected || !data) { if (!connected || !data) {

View File

@@ -1,17 +1,17 @@
export { default as BlockFormControlLabel } from './BlockFormControlLabel' export { default as BlockFormControlLabel } from './BlockFormControlLabel';
export { default as FormActions } from './FormActions' export { default as FormActions } from './FormActions';
export { default as FormButton } from './FormButton' export { default as FormButton } from './FormButton';
export { default as HighlightAvatar } from './HighlightAvatar' export { default as HighlightAvatar } from './HighlightAvatar';
export { default as MenuAppBar } from './MenuAppBar' export { default as MenuAppBar } from './MenuAppBar';
export { default as PasswordValidator } from './PasswordValidator' export { default as PasswordValidator } from './PasswordValidator';
export { default as RestFormLoader } from './RestFormLoader' export { default as RestFormLoader } from './RestFormLoader';
export { default as SectionContent } from './SectionContent' export { default as SectionContent } from './SectionContent';
export { default as WebSocketFormLoader } from './WebSocketFormLoader' export { default as WebSocketFormLoader } from './WebSocketFormLoader';
export { default as ErrorButton } from './ErrorButton' export { default as ErrorButton } from './ErrorButton';
export { default as SingleUpload } from './SingleUpload' export { default as SingleUpload } from './SingleUpload';
export * from './RestFormLoader' export * from './RestFormLoader';
export * from './RestController' export * from './RestController';
export * from './WebSocketFormLoader' export * from './WebSocketFormLoader';
export * from './WebSocketController' export * from './WebSocketController';

View File

@@ -5,21 +5,26 @@ export interface FeaturesContextValue {
features: Features; features: Features;
} }
const FeaturesContextDefaultValue = {} as FeaturesContextValue const FeaturesContextDefaultValue = {} as FeaturesContextValue;
export const FeaturesContext = React.createContext( export const FeaturesContext = React.createContext(FeaturesContextDefaultValue);
FeaturesContextDefaultValue
);
export interface WithFeaturesProps { export interface WithFeaturesProps {
features: Features; features: Features;
} }
export function withFeatures<T extends WithFeaturesProps>(Component: React.ComponentType<T>) { export function withFeatures<T extends WithFeaturesProps>(
Component: React.ComponentType<T>
) {
return class extends React.Component<Omit<T, keyof WithFeaturesProps>> { return class extends React.Component<Omit<T, keyof WithFeaturesProps>> {
render() { render() {
return ( return (
<FeaturesContext.Consumer> <FeaturesContext.Consumer>
{featuresContext => <Component {...this.props as T} features={featuresContext.features} />} {(featuresContext) => (
<Component
{...(this.props as T)}
features={featuresContext.features}
/>
)}
</FeaturesContext.Consumer> </FeaturesContext.Consumer>
); );
} }

View File

@@ -1,4 +1,4 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { Features } from './types'; import { Features } from './types';
import { FeaturesContext } from './FeaturesContext'; import { FeaturesContext } from './FeaturesContext';
@@ -9,10 +9,9 @@ import { FEATURES_ENDPOINT } from '../api';
interface FeaturesWrapperState { interface FeaturesWrapperState {
features?: Features; features?: Features;
error?: string; error?: string;
}; }
class FeaturesWrapper extends Component<{}, FeaturesWrapperState> { class FeaturesWrapper extends Component<{}, FeaturesWrapperState> {
state: FeaturesWrapperState = {}; state: FeaturesWrapperState = {};
componentDidMount() { componentDidMount() {
@@ -21,41 +20,39 @@ class FeaturesWrapper extends Component<{}, FeaturesWrapperState> {
fetchFeaturesDetails = () => { fetchFeaturesDetails = () => {
fetch(FEATURES_ENDPOINT) fetch(FEATURES_ENDPOINT)
.then(response => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
return response.json(); return response.json();
} else { } else {
throw Error("Unexpected status code: " + response.status); throw Error('Unexpected status code: ' + response.status);
} }
}).then(features => { })
.then((features) => {
this.setState({ features }); this.setState({ features });
}) })
.catch(error => { .catch((error) => {
this.setState({ error: error.message }); this.setState({ error: error.message });
}); });
} };
render() { render() {
const { features, error } = this.state; const { features, error } = this.state;
if (features) { if (features) {
return ( return (
<FeaturesContext.Provider value={{ <FeaturesContext.Provider
features value={{
}}> features
}}
>
{this.props.children} {this.props.children}
</FeaturesContext.Provider> </FeaturesContext.Provider>
); );
} }
if (error) { if (error) {
return ( return <ApplicationError error={error} />;
<ApplicationError error={error} />
);
} }
return ( return <FullScreenLoading />;
<FullScreenLoading />
);
} }
} }
export default FeaturesWrapper; export default FeaturesWrapper;

View File

@@ -1,8 +1,8 @@
export interface Features { export interface Features {
project: boolean project: boolean;
security: boolean security: boolean;
mqtt: boolean mqtt: boolean;
ntp: boolean ntp: boolean;
ota: boolean ota: boolean;
upload_firmware: boolean upload_firmware: boolean;
} }

View File

@@ -1,5 +1,5 @@
import { createBrowserHistory } from 'history' import { createBrowserHistory } from 'history';
export default createBrowserHistory({ export default createBrowserHistory({
/* pass a configuration object here if needed */ /* pass a configuration object here if needed */
}) });

View File

@@ -6,8 +6,9 @@ import { Router } from 'react-router';
import App from './App'; import App from './App';
render(( render(
<Router history={history}> <Router history={history}>
<App /> <App />
</Router> </Router>,
), document.getElementById("root")) document.getElementById('root')
);

View File

@@ -1,9 +1,13 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
import { Tabs, Tab } from '@material-ui/core'; import { Tabs, Tab } from '@material-ui/core';
import { AuthenticatedContextProps, withAuthenticatedContext, AuthenticatedRoute } from '../authentication'; import {
AuthenticatedContextProps,
withAuthenticatedContext,
AuthenticatedRoute
} from '../authentication';
import { MenuAppBar } from '../components'; import { MenuAppBar } from '../components';
import MqttStatusController from './MqttStatusController'; import MqttStatusController from './MqttStatusController';
import MqttSettingsController from './MqttSettingsController'; import MqttSettingsController from './MqttSettingsController';
@@ -11,8 +15,7 @@ import MqttSettingsController from './MqttSettingsController';
type MqttProps = AuthenticatedContextProps & RouteComponentProps; type MqttProps = AuthenticatedContextProps & RouteComponentProps;
class Mqtt extends Component<MqttProps> { class Mqtt extends Component<MqttProps> {
handleTabChange = (path: string) => {
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
this.props.history.push(path); this.props.history.push(path);
}; };
@@ -20,17 +23,33 @@ class Mqtt extends Component<MqttProps> {
const { authenticatedContext } = this.props; const { authenticatedContext } = this.props;
return ( return (
<MenuAppBar sectionTitle="MQTT"> <MenuAppBar sectionTitle="MQTT">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> <Tabs
value={this.props.match.url}
onChange={(e, path) => this.handleTabChange(path)}
variant="fullWidth"
>
<Tab value="/mqtt/status" label="MQTT Status" /> <Tab value="/mqtt/status" label="MQTT Status" />
<Tab value="/mqtt/settings" label="MQTT Settings" disabled={!authenticatedContext.me.admin} /> <Tab
value="/mqtt/settings"
label="MQTT Settings"
disabled={!authenticatedContext.me.admin}
/>
</Tabs> </Tabs>
<Switch> <Switch>
<AuthenticatedRoute exact path="/mqtt/status" component={MqttStatusController} /> <AuthenticatedRoute
<AuthenticatedRoute exact path="/mqtt/settings" component={MqttSettingsController} /> exact
path="/mqtt/status"
component={MqttStatusController}
/>
<AuthenticatedRoute
exact
path="/mqtt/settings"
component={MqttSettingsController}
/>
<Redirect to="/mqtt/status" /> <Redirect to="/mqtt/status" />
</Switch> </Switch>
</MenuAppBar> </MenuAppBar>
) );
} }
} }

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { MQTT_SETTINGS_ENDPOINT } from '../api'; import { MQTT_SETTINGS_ENDPOINT } from '../api';
import MqttSettingsForm from './MqttSettingsForm'; import MqttSettingsForm from './MqttSettingsForm';
@@ -9,7 +14,6 @@ import { MqttSettings } from './types';
type MqttSettingsControllerProps = RestControllerProps<MqttSettings>; type MqttSettingsControllerProps = RestControllerProps<MqttSettings>;
class MqttSettingsController extends Component<MqttSettingsControllerProps> { class MqttSettingsController extends Component<MqttSettingsControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -19,12 +23,11 @@ class MqttSettingsController extends Component<MqttSettingsControllerProps> {
<SectionContent title="MQTT Settings" titleGutter> <SectionContent title="MQTT Settings" titleGutter>
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <MqttSettingsForm {...formProps} />} render={(formProps) => <MqttSettingsForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
) );
} }
} }
export default restController(MQTT_SETTINGS_ENDPOINT, MqttSettingsController); export default restController(MQTT_SETTINGS_ENDPOINT, MqttSettingsController);

View File

@@ -1,31 +1,31 @@
import React from "react"; import React from 'react';
import { import {
TextValidator, TextValidator,
ValidatorForm, ValidatorForm,
SelectValidator, SelectValidator
} from "react-material-ui-form-validator"; } from 'react-material-ui-form-validator';
import { Checkbox, TextField, Typography } from "@material-ui/core"; import { Checkbox, TextField, Typography } from '@material-ui/core';
import SaveIcon from "@material-ui/icons/Save"; import SaveIcon from '@material-ui/icons/Save';
import MenuItem from "@material-ui/core/MenuItem"; import MenuItem from '@material-ui/core/MenuItem';
import { import {
RestFormProps, RestFormProps,
FormActions, FormActions,
FormButton, FormButton,
BlockFormControlLabel, BlockFormControlLabel,
PasswordValidator, PasswordValidator
} from "../components"; } from '../components';
import { isIP, isHostname, or, isPath } from "../validators"; import { isIP, isHostname, or, isPath } from '../validators';
import { MqttSettings } from "./types"; import { MqttSettings } from './types';
type MqttSettingsFormProps = RestFormProps<MqttSettings>; type MqttSettingsFormProps = RestFormProps<MqttSettings>;
class MqttSettingsForm extends React.Component<MqttSettingsFormProps> { class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
componentDidMount() { componentDidMount() {
ValidatorForm.addValidationRule("isIPOrHostname", or(isIP, isHostname)); ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
ValidatorForm.addValidationRule("isPath", isPath); ValidatorForm.addValidationRule('isPath', isPath);
} }
render() { render() {
@@ -36,38 +36,38 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
control={ control={
<Checkbox <Checkbox
checked={data.enabled} checked={data.enabled}
onChange={handleValueChange("enabled")} onChange={handleValueChange('enabled')}
value="enabled" value="enabled"
/> />
} }
label="Enable MQTT" label="Enable MQTT"
/> />
<TextValidator <TextValidator
validators={["required", "isIPOrHostname"]} validators={['required', 'isIPOrHostname']}
errorMessages={[ errorMessages={[
"Host is required", 'Host is required',
"Not a valid IP address or hostname", 'Not a valid IP address or hostname'
]} ]}
name="host" name="host"
label="Host" label="Host"
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.host} value={data.host}
onChange={handleValueChange("host")} onChange={handleValueChange('host')}
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:65535", 'maxNumber:65535'
]} ]}
errorMessages={[ errorMessages={[
"Port is required", 'Port is required',
"Must be a number", 'Must be a number',
"Must be greater than 0 ", 'Must be greater than 0 ',
"Max value is 65535", 'Max value is 65535'
]} ]}
name="port" name="port"
label="Port" label="Port"
@@ -75,18 +75,18 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.port} value={data.port}
type="number" type="number"
onChange={handleValueChange("port")} onChange={handleValueChange('port')}
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={["required", "isPath"]} validators={['required', 'isPath']}
errorMessages={["Base is required", "Not a valid Path"]} errorMessages={['Base is required', 'Not a valid Path']}
name="base" name="base"
label="Base" label="Base"
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.base} value={data.base}
onChange={handleValueChange("base")} onChange={handleValueChange('base')}
margin="normal" margin="normal"
/> />
<TextField <TextField
@@ -95,7 +95,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.username} value={data.username}
onChange={handleValueChange("username")} onChange={handleValueChange('username')}
margin="normal" margin="normal"
/> />
<PasswordValidator <PasswordValidator
@@ -104,7 +104,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.password} value={data.password}
onChange={handleValueChange("password")} onChange={handleValueChange('password')}
margin="normal" margin="normal"
/> />
<TextField <TextField
@@ -113,21 +113,21 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.client_id} value={data.client_id}
onChange={handleValueChange("client_id")} onChange={handleValueChange('client_id')}
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:1", 'minNumber:1',
"maxNumber:65535", 'maxNumber:65535'
]} ]}
errorMessages={[ errorMessages={[
"Keep alive is required", 'Keep alive is required',
"Must be a number", 'Must be a number',
"Must be greater than 0", 'Must be greater than 0',
"Max value is 65535", 'Max value is 65535'
]} ]}
name="keep_alive" name="keep_alive"
label="Keep Alive (seconds)" label="Keep Alive (seconds)"
@@ -135,7 +135,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.keep_alive} value={data.keep_alive}
type="number" type="number"
onChange={handleValueChange("keep_alive")} onChange={handleValueChange('keep_alive')}
margin="normal" margin="normal"
/> />
<SelectValidator <SelectValidator
@@ -144,7 +144,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
value={data.mqtt_qos} value={data.mqtt_qos}
fullWidth fullWidth
variant="outlined" variant="outlined"
onChange={handleValueChange("mqtt_qos")} onChange={handleValueChange('mqtt_qos')}
margin="normal" margin="normal"
> >
<MenuItem value={0}>0 (default)</MenuItem> <MenuItem value={0}>0 (default)</MenuItem>
@@ -155,7 +155,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
control={ control={
<Checkbox <Checkbox
checked={data.clean_session} checked={data.clean_session}
onChange={handleValueChange("clean_session")} onChange={handleValueChange('clean_session')}
value="clean_session" value="clean_session"
/> />
} }
@@ -165,7 +165,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
control={ control={
<Checkbox <Checkbox
checked={data.mqtt_retain} checked={data.mqtt_retain}
onChange={handleValueChange("mqtt_retain")} onChange={handleValueChange('mqtt_retain')}
value="mqtt_retain" value="mqtt_retain"
/> />
} }
@@ -181,7 +181,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
value={data.nested_format} value={data.nested_format}
fullWidth fullWidth
variant="outlined" variant="outlined"
onChange={handleValueChange("nested_format")} onChange={handleValueChange('nested_format')}
margin="normal" margin="normal"
> >
<MenuItem value={1}>nested on a single topic</MenuItem> <MenuItem value={1}>nested on a single topic</MenuItem>
@@ -193,7 +193,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
value={data.dallas_format} value={data.dallas_format}
fullWidth fullWidth
variant="outlined" variant="outlined"
onChange={handleValueChange("dallas_format")} onChange={handleValueChange('dallas_format')}
margin="normal" margin="normal"
> >
<MenuItem value={1}>by Sensor ID</MenuItem> <MenuItem value={1}>by Sensor ID</MenuItem>
@@ -205,7 +205,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
value={data.bool_format} value={data.bool_format}
fullWidth fullWidth
variant="outlined" variant="outlined"
onChange={handleValueChange("bool_format")} onChange={handleValueChange('bool_format')}
margin="normal" margin="normal"
> >
<MenuItem value={1}>"on"/"off"</MenuItem> <MenuItem value={1}>"on"/"off"</MenuItem>
@@ -219,7 +219,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
value={data.subscribe_format} value={data.subscribe_format}
fullWidth fullWidth
variant="outlined" variant="outlined"
onChange={handleValueChange("subscribe_format")} onChange={handleValueChange('subscribe_format')}
margin="normal" margin="normal"
> >
<MenuItem value={0}>general device topic</MenuItem> <MenuItem value={0}>general device topic</MenuItem>
@@ -230,7 +230,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
control={ control={
<Checkbox <Checkbox
checked={data.ha_enabled} checked={data.ha_enabled}
onChange={handleValueChange("ha_enabled")} onChange={handleValueChange('ha_enabled')}
value="ha_enabled" value="ha_enabled"
/> />
} }
@@ -243,7 +243,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
value={data.ha_climate_format} value={data.ha_climate_format}
fullWidth fullWidth
variant="outlined" variant="outlined"
onChange={handleValueChange("ha_climate_format")} onChange={handleValueChange('ha_climate_format')}
margin="normal" margin="normal"
> >
<MenuItem value={1}>use Current temperature (default)</MenuItem> <MenuItem value={1}>use Current temperature (default)</MenuItem>
@@ -257,16 +257,16 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
</Typography> </Typography>
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:65535", 'maxNumber:65535'
]} ]}
errorMessages={[ errorMessages={[
"Publish time is required", 'Publish time is required',
"Must be a number", 'Must be a number',
"Must be 0 or greater", 'Must be 0 or greater',
"Max value is 65535", 'Max value is 65535'
]} ]}
name="publish_time_boiler" name="publish_time_boiler"
label="Boiler Publish Interval (seconds, 0=on change)" label="Boiler Publish Interval (seconds, 0=on change)"
@@ -274,21 +274,21 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.publish_time_boiler} value={data.publish_time_boiler}
type="number" type="number"
onChange={handleValueChange("publish_time_boiler")} onChange={handleValueChange('publish_time_boiler')}
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:65535", 'maxNumber:65535'
]} ]}
errorMessages={[ errorMessages={[
"Publish time is required", 'Publish time is required',
"Must be a number", 'Must be a number',
"Must be 0 or greater", 'Must be 0 or greater',
"Max value is 65535", 'Max value is 65535'
]} ]}
name="publish_time_thermostat" name="publish_time_thermostat"
label="Thermostat Publish Interval (seconds, 0=on change)" label="Thermostat Publish Interval (seconds, 0=on change)"
@@ -296,21 +296,21 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.publish_time_thermostat} value={data.publish_time_thermostat}
type="number" type="number"
onChange={handleValueChange("publish_time_thermostat")} onChange={handleValueChange('publish_time_thermostat')}
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:65535", 'maxNumber:65535'
]} ]}
errorMessages={[ errorMessages={[
"Publish time is required", 'Publish time is required',
"Must be a number", 'Must be a number',
"Must be 0 or greater", 'Must be 0 or greater',
"Max value is 65535", 'Max value is 65535'
]} ]}
name="publish_time_solar" name="publish_time_solar"
label="Solar Publish Interval (seconds, 0=on change)" label="Solar Publish Interval (seconds, 0=on change)"
@@ -318,21 +318,21 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.publish_time_solar} value={data.publish_time_solar}
type="number" type="number"
onChange={handleValueChange("publish_time_solar")} onChange={handleValueChange('publish_time_solar')}
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:65535", 'maxNumber:65535'
]} ]}
errorMessages={[ errorMessages={[
"Publish time is required", 'Publish time is required',
"Must be a number", 'Must be a number',
"Must be 0 or greater", 'Must be 0 or greater',
"Max value is 65535", 'Max value is 65535'
]} ]}
name="publish_time_mixer" name="publish_time_mixer"
label="Mixer Publish Interval (seconds, 0=on change)" label="Mixer Publish Interval (seconds, 0=on change)"
@@ -340,21 +340,21 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.publish_time_mixer} value={data.publish_time_mixer}
type="number" type="number"
onChange={handleValueChange("publish_time_mixer")} onChange={handleValueChange('publish_time_mixer')}
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:65535", 'maxNumber:65535'
]} ]}
errorMessages={[ errorMessages={[
"Publish time is required", 'Publish time is required',
"Must be a number", 'Must be a number',
"Must be 0 or greater", 'Must be 0 or greater',
"Max value is 65535", 'Max value is 65535'
]} ]}
name="publish_time_sensor" name="publish_time_sensor"
label="Sensors Publish Interval (seconds, 0=on change)" label="Sensors Publish Interval (seconds, 0=on change)"
@@ -362,21 +362,21 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.publish_time_sensor} value={data.publish_time_sensor}
type="number" type="number"
onChange={handleValueChange("publish_time_sensor")} onChange={handleValueChange('publish_time_sensor')}
margin="normal" margin="normal"
/> />
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:65535", 'maxNumber:65535'
]} ]}
errorMessages={[ errorMessages={[
"Publish time is required", 'Publish time is required',
"Must be a number", 'Must be a number',
"Must be 0 or greater", 'Must be 0 or greater',
"Max value is 65535", 'Max value is 65535'
]} ]}
name="publish_time_other" name="publish_time_other"
label="All other Modules Publish Interval (seconds, 0=on change)" label="All other Modules Publish Interval (seconds, 0=on change)"
@@ -384,7 +384,7 @@ class MqttSettingsForm extends React.Component<MqttSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.publish_time_other} value={data.publish_time_other}
type="number" type="number"
onChange={handleValueChange("publish_time_other")} onChange={handleValueChange('publish_time_other')}
margin="normal" margin="normal"
/> />
<FormActions> <FormActions>

View File

@@ -1,59 +1,59 @@
import { Theme } from '@material-ui/core' import { Theme } from '@material-ui/core';
import { MqttStatus, MqttDisconnectReason } from './types' import { MqttStatus, MqttDisconnectReason } from './types';
export const mqttStatusHighlight = ( export const mqttStatusHighlight = (
{ enabled, connected }: MqttStatus, { enabled, connected }: MqttStatus,
theme: Theme, theme: Theme
) => { ) => {
if (!enabled) { if (!enabled) {
return theme.palette.info.main return theme.palette.info.main;
} }
if (connected) { if (connected) {
return theme.palette.success.main return theme.palette.success.main;
} }
return theme.palette.error.main return theme.palette.error.main;
} };
export const mqttStatus = ({ enabled, connected }: MqttStatus) => { export const mqttStatus = ({ enabled, connected }: MqttStatus) => {
if (!enabled) { if (!enabled) {
return 'Not enabled' return 'Not enabled';
} }
if (connected) { if (connected) {
return 'Connected' return 'Connected';
} }
return 'Disconnected' return 'Disconnected';
} };
export const disconnectReason = ({ disconnect_reason }: MqttStatus) => { export const disconnectReason = ({ disconnect_reason }: MqttStatus) => {
switch (disconnect_reason) { switch (disconnect_reason) {
case MqttDisconnectReason.TCP_DISCONNECTED: case MqttDisconnectReason.TCP_DISCONNECTED:
return 'TCP disconnected' return 'TCP disconnected';
case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION: case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
return 'Unacceptable protocol version' return 'Unacceptable protocol version';
case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED: case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
return 'Client ID rejected' return 'Client ID rejected';
case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE: case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
return 'Server unavailable' return 'Server unavailable';
case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS: case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
return 'Malformed credentials' return 'Malformed credentials';
case MqttDisconnectReason.MQTT_NOT_AUTHORIZED: case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
return 'Not authorized' return 'Not authorized';
case MqttDisconnectReason.ESP8266_NOT_ENOUGH_SPACE: case MqttDisconnectReason.ESP8266_NOT_ENOUGH_SPACE:
return 'Device out of memory' return 'Device out of memory';
case MqttDisconnectReason.TLS_BAD_FINGERPRINT: case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
return 'Server fingerprint invalid' return 'Server fingerprint invalid';
default: default:
return 'Unknown' return 'Unknown';
} }
} };
export const mqttPublishHighlight = ( export const mqttPublishHighlight = (
{ mqtt_fails }: MqttStatus, { mqtt_fails }: MqttStatus,
theme: Theme, theme: Theme
) => { ) => {
if (mqtt_fails === 0) return theme.palette.success.main if (mqtt_fails === 0) return theme.palette.success.main;
if (mqtt_fails < 10) return theme.palette.warning.main if (mqtt_fails < 10) return theme.palette.warning.main;
return theme.palette.error.main return theme.palette.error.main;
} };

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { MQTT_STATUS_ENDPOINT } from '../api'; import { MQTT_STATUS_ENDPOINT } from '../api';
import MqttStatusForm from './MqttStatusForm'; import MqttStatusForm from './MqttStatusForm';
@@ -9,7 +14,6 @@ import { MqttStatus } from './types';
type MqttStatusControllerProps = RestControllerProps<MqttStatus>; type MqttStatusControllerProps = RestControllerProps<MqttStatus>;
class MqttStatusController extends Component<MqttStatusControllerProps> { class MqttStatusController extends Component<MqttStatusControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -19,10 +23,10 @@ class MqttStatusController extends Component<MqttStatusControllerProps> {
<SectionContent title="MQTT Status"> <SectionContent title="MQTT Status">
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <MqttStatusForm {...formProps} />} render={(formProps) => <MqttStatusForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
) );
} }
} }

View File

@@ -1,23 +1,39 @@
import React, { Component, Fragment } from 'react'; import { Component, Fragment } from 'react';
import { WithTheme, withTheme } from '@material-ui/core/styles'; import { WithTheme, withTheme } from '@material-ui/core/styles';
import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText } from '@material-ui/core'; import {
Avatar,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText
} from '@material-ui/core';
import DeviceHubIcon from '@material-ui/icons/DeviceHub'; import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import RefreshIcon from '@material-ui/icons/Refresh'; import RefreshIcon from '@material-ui/icons/Refresh';
import ReportIcon from '@material-ui/icons/Report'; import ReportIcon from '@material-ui/icons/Report';
import SpeakerNotesOffIcon from "@material-ui/icons/SpeakerNotesOff"; import SpeakerNotesOffIcon from '@material-ui/icons/SpeakerNotesOff';
import { RestFormProps, FormActions, FormButton, HighlightAvatar } from '../components'; import {
import { mqttStatusHighlight, mqttStatus, mqttPublishHighlight, disconnectReason } from './MqttStatus'; RestFormProps,
FormActions,
FormButton,
HighlightAvatar
} from '../components';
import {
mqttStatusHighlight,
mqttStatus,
mqttPublishHighlight,
disconnectReason
} from './MqttStatus';
import { MqttStatus } from './types'; import { MqttStatus } from './types';
type MqttStatusFormProps = RestFormProps<MqttStatus> & WithTheme; type MqttStatusFormProps = RestFormProps<MqttStatus> & WithTheme;
class MqttStatusForm extends Component<MqttStatusFormProps> { class MqttStatusForm extends Component<MqttStatusFormProps> {
renderConnectionStatus() { renderConnectionStatus() {
const { data, theme } = this.props const { data, theme } = this.props;
if (data.connected) { if (data.connected) {
return ( return (
<Fragment> <Fragment>
@@ -50,7 +66,10 @@ class MqttStatusForm extends Component<MqttStatusFormProps> {
<ReportIcon /> <ReportIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Disconnect Reason" secondary={disconnectReason(data)} /> <ListItemText
primary="Disconnect Reason"
secondary={disconnectReason(data)}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</Fragment> </Fragment>
@@ -58,7 +77,7 @@ class MqttStatusForm extends Component<MqttStatusFormProps> {
} }
createListItems() { createListItems() {
const { data, theme } = this.props const { data, theme } = this.props;
return ( return (
<Fragment> <Fragment>
<ListItem> <ListItem>
@@ -78,18 +97,20 @@ class MqttStatusForm extends Component<MqttStatusFormProps> {
render() { render() {
return ( return (
<Fragment> <Fragment>
<List> <List>{this.createListItems()}</List>
{this.createListItems()}
</List>
<FormActions> <FormActions>
<FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}> <FormButton
startIcon={<RefreshIcon />}
variant="contained"
color="secondary"
onClick={this.props.loadData}
>
Refresh Refresh
</FormButton> </FormButton>
</FormActions> </FormActions>
</Fragment> </Fragment>
); );
} }
} }
export default withTheme(MqttStatusForm); export default withTheme(MqttStatusForm);

View File

@@ -6,40 +6,40 @@ export enum MqttDisconnectReason {
MQTT_MALFORMED_CREDENTIALS = 4, MQTT_MALFORMED_CREDENTIALS = 4,
MQTT_NOT_AUTHORIZED = 5, MQTT_NOT_AUTHORIZED = 5,
ESP8266_NOT_ENOUGH_SPACE = 6, ESP8266_NOT_ENOUGH_SPACE = 6,
TLS_BAD_FINGERPRINT = 7, TLS_BAD_FINGERPRINT = 7
} }
export interface MqttStatus { export interface MqttStatus {
enabled: boolean enabled: boolean;
connected: boolean connected: boolean;
client_id: string client_id: string;
disconnect_reason: MqttDisconnectReason disconnect_reason: MqttDisconnectReason;
mqtt_fails: number mqtt_fails: number;
} }
export interface MqttSettings { export interface MqttSettings {
enabled: boolean enabled: boolean;
host: string host: string;
port: number port: number;
base: string base: string;
username: string username: string;
password: string password: string;
client_id: string client_id: string;
keep_alive: number keep_alive: number;
clean_session: boolean clean_session: boolean;
max_topic_length: number max_topic_length: number;
publish_time_boiler: number publish_time_boiler: number;
publish_time_thermostat: number publish_time_thermostat: number;
publish_time_solar: number publish_time_solar: number;
publish_time_mixer: number publish_time_mixer: number;
publish_time_other: number publish_time_other: number;
publish_time_sensor: number publish_time_sensor: number;
dallas_format: number dallas_format: number;
bool_format: number bool_format: number;
mqtt_qos: number mqtt_qos: number;
mqtt_retain: boolean mqtt_retain: boolean;
ha_enabled: boolean ha_enabled: boolean;
ha_climate_format: number ha_climate_format: number;
nested_format: number nested_format: number;
subscribe_format: number subscribe_format: number;
} }

View File

@@ -1,22 +1,31 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
import { Tabs, Tab } from '@material-ui/core'; import { Tabs, Tab } from '@material-ui/core';
import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication'; import {
withAuthenticatedContext,
AuthenticatedContextProps,
AuthenticatedRoute
} from '../authentication';
import { MenuAppBar } from '../components'; import { MenuAppBar } from '../components';
import NetworkStatusController from './NetworkStatusController'; import NetworkStatusController from './NetworkStatusController';
import NetworkSettingsController from './NetworkSettingsController'; import NetworkSettingsController from './NetworkSettingsController';
import WiFiNetworkScanner from './WiFiNetworkScanner'; import WiFiNetworkScanner from './WiFiNetworkScanner';
import { NetworkConnectionContext, NetworkConnectionContextValue } from './NetworkConnectionContext'; import {
NetworkConnectionContext,
NetworkConnectionContextValue
} from './NetworkConnectionContext';
import { WiFiNetwork } from './types'; import { WiFiNetwork } from './types';
type NetworkConnectionProps = AuthenticatedContextProps & RouteComponentProps; type NetworkConnectionProps = AuthenticatedContextProps & RouteComponentProps;
class NetworkConnection extends Component<NetworkConnectionProps, NetworkConnectionContextValue> { class NetworkConnection extends Component<
NetworkConnectionProps,
NetworkConnectionContextValue
> {
constructor(props: NetworkConnectionProps) { constructor(props: NetworkConnectionProps) {
super(props); super(props);
this.state = { this.state = {
@@ -28,13 +37,13 @@ class NetworkConnection extends Component<NetworkConnectionProps, NetworkConnect
selectNetwork = (network: WiFiNetwork) => { selectNetwork = (network: WiFiNetwork) => {
this.setState({ selectedNetwork: network }); this.setState({ selectedNetwork: network });
this.props.history.push('/network/settings'); this.props.history.push('/network/settings');
} };
deselectNetwork = () => { deselectNetwork = () => {
this.setState({ selectedNetwork: undefined }); this.setState({ selectedNetwork: undefined });
} };
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => { handleTabChange = (path: string) => {
this.props.history.push(path); this.props.history.push(path);
}; };
@@ -43,20 +52,44 @@ class NetworkConnection extends Component<NetworkConnectionProps, NetworkConnect
return ( return (
<NetworkConnectionContext.Provider value={this.state}> <NetworkConnectionContext.Provider value={this.state}>
<MenuAppBar sectionTitle="Network Connection"> <MenuAppBar sectionTitle="Network Connection">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> <Tabs
value={this.props.match.url}
onChange={(e, path) => this.handleTabChange(path)}
variant="fullWidth"
>
<Tab value="/network/status" label="Network Status" /> <Tab value="/network/status" label="Network Status" />
<Tab value="/network/scan" label="Scan WiFi Networks" disabled={!authenticatedContext.me.admin} /> <Tab
<Tab value="/network/settings" label="Network Settings" disabled={!authenticatedContext.me.admin} /> value="/network/scan"
label="Scan WiFi Networks"
disabled={!authenticatedContext.me.admin}
/>
<Tab
value="/network/settings"
label="Network Settings"
disabled={!authenticatedContext.me.admin}
/>
</Tabs> </Tabs>
<Switch> <Switch>
<AuthenticatedRoute exact path="/network/status" component={NetworkStatusController} /> <AuthenticatedRoute
<AuthenticatedRoute exact path="/network/scan" component={WiFiNetworkScanner} /> exact
<AuthenticatedRoute exact path="/network/settings" component={NetworkSettingsController} /> path="/network/status"
component={NetworkStatusController}
/>
<AuthenticatedRoute
exact
path="/network/scan"
component={WiFiNetworkScanner}
/>
<AuthenticatedRoute
exact
path="/network/settings"
component={NetworkSettingsController}
/>
<Redirect to="/network/status" /> <Redirect to="/network/status" />
</Switch> </Switch>
</MenuAppBar> </MenuAppBar>
</NetworkConnectionContext.Provider> </NetworkConnectionContext.Provider>
) );
} }
} }

View File

@@ -7,7 +7,7 @@ export interface NetworkConnectionContextValue {
deselectNetwork: () => void; deselectNetwork: () => void;
} }
const NetworkConnectionContextDefaultValue = {} as NetworkConnectionContextValue const NetworkConnectionContextDefaultValue = {} as NetworkConnectionContextValue;
export const NetworkConnectionContext = React.createContext( export const NetworkConnectionContext = React.createContext(
NetworkConnectionContextDefaultValue NetworkConnectionContextDefaultValue
); );

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import NetworkSettingsForm from './NetworkSettingsForm'; import NetworkSettingsForm from './NetworkSettingsForm';
import { NETWORK_SETTINGS_ENDPOINT } from '../api'; import { NETWORK_SETTINGS_ENDPOINT } from '../api';
import { NetworkSettings } from './types'; import { NetworkSettings } from './types';
@@ -8,7 +13,6 @@ import { NetworkSettings } from './types';
type NetworkSettingsControllerProps = RestControllerProps<NetworkSettings>; type NetworkSettingsControllerProps = RestControllerProps<NetworkSettings>;
class NetworkSettingsController extends Component<NetworkSettingsControllerProps> { class NetworkSettingsController extends Component<NetworkSettingsControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -18,12 +22,14 @@ class NetworkSettingsController extends Component<NetworkSettingsControllerProps
<SectionContent title="Network Settings"> <SectionContent title="Network Settings">
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <NetworkSettingsForm {...formProps} />} render={(formProps) => <NetworkSettingsForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
); );
} }
} }
export default restController(NETWORK_SETTINGS_ENDPOINT, NetworkSettingsController); export default restController(
NETWORK_SETTINGS_ENDPOINT,
NetworkSettingsController
);

View File

@@ -1,7 +1,14 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator'; import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import { Checkbox, List, ListItem, ListItemText, ListItemAvatar, ListItemSecondaryAction } from '@material-ui/core'; import {
Checkbox,
List,
ListItem,
ListItemText,
ListItemAvatar,
ListItemSecondaryAction
} from '@material-ui/core';
import Avatar from '@material-ui/core/Avatar'; import Avatar from '@material-ui/core/Avatar';
import IconButton from '@material-ui/core/IconButton'; import IconButton from '@material-ui/core/IconButton';
@@ -10,31 +17,42 @@ import LockOpenIcon from '@material-ui/icons/LockOpen';
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from '@material-ui/icons/Delete';
import SaveIcon from '@material-ui/icons/Save'; import SaveIcon from '@material-ui/icons/Save';
import { RestFormProps, PasswordValidator, BlockFormControlLabel, FormActions, FormButton } from '../components'; import {
RestFormProps,
PasswordValidator,
BlockFormControlLabel,
FormActions,
FormButton
} from '../components';
import { isIP, isHostname, optional } from '../validators'; import { isIP, isHostname, optional } from '../validators';
import { NetworkConnectionContext, NetworkConnectionContextValue } from './NetworkConnectionContext'; import {
NetworkConnectionContext,
NetworkConnectionContextValue
} from './NetworkConnectionContext';
import { isNetworkOpen, networkSecurityMode } from './WiFiSecurityModes'; import { isNetworkOpen, networkSecurityMode } from './WiFiSecurityModes';
import { NetworkSettings } from './types'; import { NetworkSettings } from './types';
type NetworkStatusFormProps = RestFormProps<NetworkSettings>; type NetworkStatusFormProps = RestFormProps<NetworkSettings>;
class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> { class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
static contextType = NetworkConnectionContext; static contextType = NetworkConnectionContext;
context!: React.ContextType<typeof NetworkConnectionContext>; context!: React.ContextType<typeof NetworkConnectionContext>;
constructor(props: NetworkStatusFormProps, context: NetworkConnectionContextValue) { constructor(
props: NetworkStatusFormProps,
context: NetworkConnectionContextValue
) {
super(props); super(props);
const { selectedNetwork } = context; const { selectedNetwork } = context;
if (selectedNetwork) { if (selectedNetwork) {
const networkSettings: NetworkSettings = { const networkSettings: NetworkSettings = {
ssid: selectedNetwork.ssid, ssid: selectedNetwork.ssid,
password: "", password: '',
hostname: props.data.hostname, hostname: props.data.hostname,
static_ip_config: false, static_ip_config: false
} };
props.setData(networkSettings); props.setData(networkSettings);
} }
} }
@@ -48,7 +66,7 @@ class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
deselectNetworkAndLoadData = () => { deselectNetworkAndLoadData = () => {
this.context.deselectNetwork(); this.context.deselectNetwork();
this.props.loadData(); this.props.loadData();
} };
componentWillUnmount() { componentWillUnmount() {
this.context.deselectNetwork(); this.context.deselectNetwork();
@@ -59,41 +77,51 @@ class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
const { data, handleValueChange, saveData } = this.props; const { data, handleValueChange, saveData } = this.props;
return ( return (
<ValidatorForm onSubmit={saveData} ref="NetworkSettingsForm"> <ValidatorForm onSubmit={saveData} ref="NetworkSettingsForm">
{ {selectedNetwork ? (
selectedNetwork ? <List>
<List> <ListItem>
<ListItem> <ListItemAvatar>
<ListItemAvatar> <Avatar>
<Avatar> {isNetworkOpen(selectedNetwork) ? (
{isNetworkOpen(selectedNetwork) ? <LockOpenIcon /> : <LockIcon />} <LockOpenIcon />
</Avatar> ) : (
</ListItemAvatar> <LockIcon />
<ListItemText )}
primary={selectedNetwork.ssid} </Avatar>
secondary={"Security: " + networkSecurityMode(selectedNetwork) + ", Ch: " + selectedNetwork.channel} </ListItemAvatar>
/> <ListItemText
<ListItemSecondaryAction> primary={selectedNetwork.ssid}
<IconButton aria-label="Manual Config" onClick={deselectNetwork}> secondary={
<DeleteIcon /> 'Security: ' +
</IconButton> networkSecurityMode(selectedNetwork) +
</ListItemSecondaryAction> ', Ch: ' +
</ListItem> selectedNetwork.channel
</List> }
: />
<TextValidator <ListItemSecondaryAction>
validators={['matchRegexp:^.{0,32}$']} <IconButton
errorMessages={['SSID must be 32 characters or less']} aria-label="Manual Config"
name="ssid" onClick={deselectNetwork}
label="SSID (leave blank to disable WiFi)" >
fullWidth <DeleteIcon />
variant="outlined" </IconButton>
value={data.ssid} </ListItemSecondaryAction>
onChange={handleValueChange('ssid')} </ListItem>
margin="normal" </List>
/> ) : (
} <TextValidator
{ validators={['matchRegexp:^.{0,32}$']}
(!selectedNetwork || !isNetworkOpen(selectedNetwork)) && errorMessages={['SSID must be 32 characters or less']}
name="ssid"
label="SSID (leave blank to disable WiFi)"
fullWidth
variant="outlined"
value={data.ssid}
onChange={handleValueChange('ssid')}
margin="normal"
/>
)}
{(!selectedNetwork || !isNetworkOpen(selectedNetwork)) && (
<PasswordValidator <PasswordValidator
validators={['matchRegexp:^.{0,64}$']} validators={['matchRegexp:^.{0,64}$']}
errorMessages={['Password must be 64 characters or less']} errorMessages={['Password must be 64 characters or less']}
@@ -105,10 +133,10 @@ class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
onChange={handleValueChange('password')} onChange={handleValueChange('password')}
margin="normal" margin="normal"
/> />
} )}
<TextValidator <TextValidator
validators={['required', 'isHostname']} validators={['required', 'isHostname']}
errorMessages={['Hostname is required', "Not a valid hostname"]} errorMessages={['Hostname is required', 'Not a valid hostname']}
name="hostname" name="hostname"
label="Hostname" label="Hostname"
fullWidth fullWidth
@@ -122,13 +150,12 @@ class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
<Checkbox <Checkbox
value="static_ip_config" value="static_ip_config"
checked={data.static_ip_config} checked={data.static_ip_config}
onChange={handleValueChange("static_ip_config")} onChange={handleValueChange('static_ip_config')}
/> />
} }
label="Static IP Config" label="Static IP Config"
/> />
{ {data.static_ip_config && (
data.static_ip_config &&
<Fragment> <Fragment>
<TextValidator <TextValidator
validators={['required', 'isIP']} validators={['required', 'isIP']}
@@ -154,7 +181,10 @@ class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
/> />
<TextValidator <TextValidator
validators={['required', 'isIP']} validators={['required', 'isIP']}
errorMessages={['Subnet mask is required', 'Must be an IP address']} errorMessages={[
'Subnet mask is required',
'Must be an IP address'
]}
name="subnet_mask" name="subnet_mask"
label="Subnet" label="Subnet"
fullWidth fullWidth
@@ -186,9 +216,14 @@ class NetworkSettingsForm extends React.Component<NetworkStatusFormProps> {
margin="normal" margin="normal"
/> />
</Fragment> </Fragment>
} )}
<FormActions> <FormActions>
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> <FormButton
startIcon={<SaveIcon />}
variant="contained"
color="primary"
type="submit"
>
Save Save
</FormButton> </FormButton>
</FormActions> </FormActions>

View File

@@ -1,57 +1,57 @@
import { Theme } from '@material-ui/core' import { Theme } from '@material-ui/core';
import { NetworkStatus, NetworkConnectionStatus } from './types' import { NetworkStatus, NetworkConnectionStatus } from './types';
export const isConnected = ({ status }: NetworkStatus) => { export const isConnected = ({ status }: NetworkStatus) => {
return ( return (
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED || status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED
) );
} };
export const isWiFi = ({ status }: NetworkStatus) => export const isWiFi = ({ status }: NetworkStatus) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatus) => export const isEthernet = ({ status }: NetworkStatus) =>
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
export const networkStatusHighlight = ( export const networkStatusHighlight = (
{ status }: NetworkStatus, { status }: NetworkStatus,
theme: Theme, theme: Theme
) => { ) => {
switch (status) { switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE: case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED: case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD: case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return theme.palette.info.main return theme.palette.info.main;
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED: case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED: case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return theme.palette.success.main return theme.palette.success.main;
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED: case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST: case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return theme.palette.error.main return theme.palette.error.main;
default: default:
return theme.palette.warning.main return theme.palette.warning.main;
} }
} };
export const networkStatus = ({ status }: NetworkStatus) => { export const networkStatus = ({ status }: NetworkStatus) => {
switch (status) { switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD: case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return 'Inactive' return 'Inactive';
case NetworkConnectionStatus.WIFI_STATUS_IDLE: case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return 'Idle' return 'Idle';
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL: case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available' return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED: case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return 'Connected (WiFi)' return 'Connected (WiFi)';
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED: case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return 'Connected (Ethernet)' return 'Connected (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED: case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return 'Connection Failed' return 'Connection Failed';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST: case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return 'Connection Lost' return 'Connection Lost';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED: case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return 'Disconnected' return 'Disconnected';
default: default:
return 'Unknown' return 'Unknown';
} }
} };

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import NetworkStatusForm from './NetworkStatusForm'; import NetworkStatusForm from './NetworkStatusForm';
import { NETWORK_STATUS_ENDPOINT } from '../api'; import { NETWORK_STATUS_ENDPOINT } from '../api';
import { NetworkStatus } from './types'; import { NetworkStatus } from './types';
@@ -8,7 +13,6 @@ import { NetworkStatus } from './types';
type NetworkStatusControllerProps = RestControllerProps<NetworkStatus>; type NetworkStatusControllerProps = RestControllerProps<NetworkStatus>;
class NetworkStatusController extends Component<NetworkStatusControllerProps> { class NetworkStatusController extends Component<NetworkStatusControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -18,12 +22,11 @@ class NetworkStatusController extends Component<NetworkStatusControllerProps> {
<SectionContent title="Network Status"> <SectionContent title="Network Status">
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <NetworkStatusForm {...formProps} />} render={(formProps) => <NetworkStatusForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
); );
} }
} }
export default restController(NETWORK_STATUS_ENDPOINT, NetworkStatusController); export default restController(NETWORK_STATUS_ENDPOINT, NetworkStatusController);

View File

@@ -1,46 +1,46 @@
import React, { Component, Fragment } from "react"; import { Component, Fragment } from 'react';
import { WithTheme, withTheme } from "@material-ui/core/styles"; import { WithTheme, withTheme } from '@material-ui/core/styles';
import { import {
Avatar, Avatar,
Divider, Divider,
List, List,
ListItem, ListItem,
ListItemAvatar, ListItemAvatar,
ListItemText, ListItemText
} from "@material-ui/core"; } from '@material-ui/core';
import DNSIcon from "@material-ui/icons/Dns"; import DNSIcon from '@material-ui/icons/Dns';
import WifiIcon from "@material-ui/icons/Wifi"; import WifiIcon from '@material-ui/icons/Wifi';
import RouterIcon from "@material-ui/icons/Router"; import RouterIcon from '@material-ui/icons/Router';
import SettingsInputComponentIcon from "@material-ui/icons/SettingsInputComponent"; import SettingsInputComponentIcon from '@material-ui/icons/SettingsInputComponent';
import SettingsInputAntennaIcon from "@material-ui/icons/SettingsInputAntenna"; import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
import DeviceHubIcon from "@material-ui/icons/DeviceHub"; import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import RefreshIcon from "@material-ui/icons/Refresh"; import RefreshIcon from '@material-ui/icons/Refresh';
import { import {
RestFormProps, RestFormProps,
FormActions, FormActions,
FormButton, FormButton,
HighlightAvatar, HighlightAvatar
} from "../components"; } from '../components';
import { import {
networkStatus, networkStatus,
networkStatusHighlight, networkStatusHighlight,
isConnected, isConnected,
isWiFi, isWiFi,
isEthernet, isEthernet
} from "./NetworkStatus"; } from './NetworkStatus';
import { NetworkStatus } from "./types"; import { NetworkStatus } from './types';
type NetworkStatusFormProps = RestFormProps<NetworkStatus> & WithTheme; type NetworkStatusFormProps = RestFormProps<NetworkStatus> & WithTheme;
class NetworkStatusForm extends Component<NetworkStatusFormProps> { class NetworkStatusForm extends Component<NetworkStatusFormProps> {
dnsServers(status: NetworkStatus) { dnsServers(status: NetworkStatus) {
if (!status.dns_ip_1) { if (!status.dns_ip_1) {
return "none"; return 'none';
} }
return status.dns_ip_1 + (status.dns_ip_2 ? "," + status.dns_ip_2 : ""); return status.dns_ip_1 + (status.dns_ip_2 ? ',' + status.dns_ip_2 : '');
} }
createListItems() { createListItems() {
@@ -110,7 +110,7 @@ class NetworkStatusForm extends Component<NetworkStatusFormProps> {
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary="Gateway IP" primary="Gateway IP"
secondary={data.gateway_ip || "none"} secondary={data.gateway_ip || 'none'}
/> />
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />

View File

@@ -1,7 +1,14 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { withSnackbar, WithSnackbarProps } from 'notistack'; import { withSnackbar, WithSnackbarProps } from 'notistack';
import { createStyles, WithStyles, Theme, withStyles, Typography, LinearProgress } from '@material-ui/core'; import {
createStyles,
WithStyles,
Theme,
withStyles,
Typography,
LinearProgress
} from '@material-ui/core';
import PermScanWifiIcon from '@material-ui/icons/PermScanWifi'; import PermScanWifiIcon from '@material-ui/icons/PermScanWifi';
import { FormActions, FormButton, SectionContent } from '../components'; import { FormActions, FormButton, SectionContent } from '../components';
@@ -11,9 +18,9 @@ import { SCAN_NETWORKS_ENDPOINT, LIST_NETWORKS_ENDPOINT } from '../api';
import WiFiNetworkSelector from './WiFiNetworkSelector'; import WiFiNetworkSelector from './WiFiNetworkSelector';
import { WiFiNetworkList, WiFiNetwork } from './types'; import { WiFiNetworkList, WiFiNetwork } from './types';
const NUM_POLLS = 10 const NUM_POLLS = 10;
const POLLING_FREQUENCY = 500 const POLLING_FREQUENCY = 500;
const RETRY_EXCEPTION_TYPE = "retry" const RETRY_EXCEPTION_TYPE = 'retry';
interface WiFiNetworkScannerState { interface WiFiNetworkScannerState {
scanningForNetworks: boolean; scanningForNetworks: boolean;
@@ -21,28 +28,31 @@ interface WiFiNetworkScannerState {
networkList?: WiFiNetworkList; networkList?: WiFiNetworkList;
} }
const styles = (theme: Theme) => createStyles({ const styles = (theme: Theme) =>
scanningSettings: { createStyles({
margin: theme.spacing(0.5), scanningSettings: {
}, margin: theme.spacing(0.5)
scanningSettingsDetails: { },
margin: theme.spacing(4), scanningSettingsDetails: {
textAlign: "center" margin: theme.spacing(4),
}, textAlign: 'center'
scanningProgress: { },
margin: theme.spacing(4), scanningProgress: {
textAlign: "center" margin: theme.spacing(4),
} textAlign: 'center'
}); }
});
type WiFiNetworkScannerProps = WithSnackbarProps & WithStyles<typeof styles>; type WiFiNetworkScannerProps = WithSnackbarProps & WithStyles<typeof styles>;
class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkScannerState> { class WiFiNetworkScanner extends Component<
WiFiNetworkScannerProps,
pollCount: number = 0; WiFiNetworkScannerState
> {
pollCount = 0;
state: WiFiNetworkScannerState = { state: WiFiNetworkScannerState = {
scanningForNetworks: false, scanningForNetworks: false
}; };
componentDidMount() { componentDidMount() {
@@ -54,23 +64,36 @@ class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkS
if (!scanningForNetworks) { if (!scanningForNetworks) {
this.scanNetworks(); this.scanNetworks();
} }
} };
scanNetworks() { scanNetworks() {
this.pollCount = 0; this.pollCount = 0;
this.setState({ scanningForNetworks: true, networkList: undefined, errorMessage: undefined }); this.setState({
redirectingAuthorizedFetch(SCAN_NETWORKS_ENDPOINT).then(response => { scanningForNetworks: true,
if (response.status === 202) { networkList: undefined,
this.schedulePollTimeout(); errorMessage: undefined
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 });
}); });
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() { schedulePollTimeout() {
@@ -80,21 +103,20 @@ class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkS
retryError() { retryError() {
return { return {
name: RETRY_EXCEPTION_TYPE, name: RETRY_EXCEPTION_TYPE,
message: "Network list not ready, will retry in " + POLLING_FREQUENCY + "ms." message:
'Network list not ready, will retry in ' + POLLING_FREQUENCY + 'ms.'
}; };
} }
compareNetworks(network1: WiFiNetwork, network2: WiFiNetwork) { compareNetworks(network1: WiFiNetwork, network2: WiFiNetwork) {
if (network1.rssi < network2.rssi) if (network1.rssi < network2.rssi) return 1;
return 1; if (network1.rssi > network2.rssi) return -1;
if (network1.rssi > network2.rssi)
return -1;
return 0; return 0;
} }
pollNetworkList = () => { pollNetworkList = () => {
redirectingAuthorizedFetch(LIST_NETWORKS_ENDPOINT) redirectingAuthorizedFetch(LIST_NETWORKS_ENDPOINT)
.then(response => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
return response.json(); return response.json();
} }
@@ -103,24 +125,34 @@ class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkS
this.schedulePollTimeout(); this.schedulePollTimeout();
throw this.retryError(); throw this.retryError();
} else { } else {
throw Error("Device did not return network list in timely manner."); throw Error('Device did not return network list in timely manner.');
} }
} }
throw Error("Device returned unexpected response code: " + response.status); throw Error(
'Device returned unexpected response code: ' + response.status
);
}) })
.then(json => { .then((json) => {
json.networks.sort(this.compareNetworks) json.networks.sort(this.compareNetworks);
this.setState({ scanningForNetworks: false, networkList: json, errorMessage: undefined }) this.setState({
scanningForNetworks: false,
networkList: json,
errorMessage: undefined
});
}) })
.catch(error => { .catch((error) => {
if (error.name !== RETRY_EXCEPTION_TYPE) { if (error.name !== RETRY_EXCEPTION_TYPE) {
this.props.enqueueSnackbar("Problem scanning: " + error.message, { this.props.enqueueSnackbar('Problem scanning: ' + error.message, {
variant: 'error', variant: 'error'
});
this.setState({
scanningForNetworks: false,
networkList: undefined,
errorMessage: error.message
}); });
this.setState({ scanningForNetworks: false, networkList: undefined, errorMessage: error.message });
} }
}); });
} };
renderNetworkScanner() { renderNetworkScanner() {
const { classes } = this.props; const { classes } = this.props;
@@ -144,9 +176,7 @@ class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkS
</div> </div>
); );
} }
return ( return <WiFiNetworkSelector networkList={networkList} />;
<WiFiNetworkSelector networkList={networkList} />
);
} }
render() { render() {
@@ -155,14 +185,19 @@ class WiFiNetworkScanner extends Component<WiFiNetworkScannerProps, WiFiNetworkS
<SectionContent title="Network Scanner"> <SectionContent title="Network Scanner">
{this.renderNetworkScanner()} {this.renderNetworkScanner()}
<FormActions> <FormActions>
<FormButton startIcon={<PermScanWifiIcon />} variant="contained" color="secondary" onClick={this.requestNetworkScan} disabled={scanningForNetworks}> <FormButton
startIcon={<PermScanWifiIcon />}
variant="contained"
color="secondary"
onClick={this.requestNetworkScan}
disabled={scanningForNetworks}
>
Scan again&hellip; Scan again&hellip;
</FormButton> </FormButton>
</FormActions> </FormActions>
</SectionContent> </SectionContent>
); );
} }
} }
export default withSnackbar(withStyles(styles)(WiFiNetworkScanner)); export default withSnackbar(withStyles(styles)(WiFiNetworkScanner));

View File

@@ -1,7 +1,13 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Avatar, Badge } from '@material-ui/core'; import { Avatar, Badge } from '@material-ui/core';
import { List, ListItem, ListItemIcon, ListItemText, ListItemAvatar } from '@material-ui/core'; import {
List,
ListItem,
ListItemIcon,
ListItemText,
ListItemAvatar
} from '@material-ui/core';
import WifiIcon from '@material-ui/icons/Wifi'; import WifiIcon from '@material-ui/icons/Wifi';
import LockIcon from '@material-ui/icons/Lock'; import LockIcon from '@material-ui/icons/Lock';
@@ -16,13 +22,16 @@ interface WiFiNetworkSelectorProps {
} }
class WiFiNetworkSelector extends Component<WiFiNetworkSelectorProps> { class WiFiNetworkSelector extends Component<WiFiNetworkSelectorProps> {
static contextType = NetworkConnectionContext; static contextType = NetworkConnectionContext;
context!: React.ContextType<typeof NetworkConnectionContext>; context!: React.ContextType<typeof NetworkConnectionContext>;
renderNetwork = (network: WiFiNetwork) => { renderNetwork = (network: WiFiNetwork) => {
return ( return (
<ListItem key={network.bssid} button onClick={() => this.context.selectNetwork(network)}> <ListItem
key={network.bssid}
button
onClick={() => this.context.selectNetwork(network)}
>
<ListItemAvatar> <ListItemAvatar>
<Avatar> <Avatar>
{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />} {isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}
@@ -30,25 +39,27 @@ class WiFiNetworkSelector extends Component<WiFiNetworkSelectorProps> {
</ListItemAvatar> </ListItemAvatar>
<ListItemText <ListItemText
primary={network.ssid} primary={network.ssid}
secondary={"Security: " + networkSecurityMode(network) + ", Ch: " + network.channel} secondary={
'Security: ' +
networkSecurityMode(network) +
', Ch: ' +
network.channel
}
/> />
<ListItemIcon> <ListItemIcon>
<Badge badgeContent={network.rssi + "db"}> <Badge badgeContent={network.rssi + 'db'}>
<WifiIcon /> <WifiIcon />
</Badge> </Badge>
</ListItemIcon> </ListItemIcon>
</ListItem> </ListItem>
); );
} };
render() { render() {
return ( return (
<List> <List>{this.props.networkList.networks.map(this.renderNetwork)}</List>
{this.props.networkList.networks.map(this.renderNetwork)}
</List>
); );
} }
} }
export default WiFiNetworkSelector; export default WiFiNetworkSelector;

View File

@@ -1,23 +1,23 @@
import { WiFiNetwork, WiFiEncryptionType } from './types' import { WiFiNetwork, WiFiEncryptionType } from './types';
export const isNetworkOpen = ({ encryption_type }: WiFiNetwork) => export const isNetworkOpen = ({ encryption_type }: WiFiNetwork) =>
encryption_type === WiFiEncryptionType.WIFI_AUTH_OPEN encryption_type === WiFiEncryptionType.WIFI_AUTH_OPEN;
export const networkSecurityMode = ({ encryption_type }: WiFiNetwork) => { export const networkSecurityMode = ({ encryption_type }: WiFiNetwork) => {
switch (encryption_type) { switch (encryption_type) {
case WiFiEncryptionType.WIFI_AUTH_WEP: case WiFiEncryptionType.WIFI_AUTH_WEP:
return 'WEP' return 'WEP';
case WiFiEncryptionType.WIFI_AUTH_WPA_PSK: case WiFiEncryptionType.WIFI_AUTH_WPA_PSK:
return 'WPA' return 'WPA';
case WiFiEncryptionType.WIFI_AUTH_WPA2_PSK: case WiFiEncryptionType.WIFI_AUTH_WPA2_PSK:
return 'WPA2' return 'WPA2';
case WiFiEncryptionType.WIFI_AUTH_WPA_WPA2_PSK: case WiFiEncryptionType.WIFI_AUTH_WPA_WPA2_PSK:
return 'WPA/WPA2' return 'WPA/WPA2';
case WiFiEncryptionType.WIFI_AUTH_WPA2_ENTERPRISE: case WiFiEncryptionType.WIFI_AUTH_WPA2_ENTERPRISE:
return 'WPA2 Enterprise' return 'WPA2 Enterprise';
case WiFiEncryptionType.WIFI_AUTH_OPEN: case WiFiEncryptionType.WIFI_AUTH_OPEN:
return 'None' return 'None';
default: default:
return 'Unknown' return 'Unknown';
} }
} };

View File

@@ -6,7 +6,7 @@ export enum NetworkConnectionStatus {
WIFI_STATUS_CONNECTION_LOST = 5, WIFI_STATUS_CONNECTION_LOST = 5,
WIFI_STATUS_DISCONNECTED = 6, WIFI_STATUS_DISCONNECTED = 6,
ETHERNET_STATUS_CONNECTED = 10, ETHERNET_STATUS_CONNECTED = 10,
WIFI_STATUS_NO_SHIELD = 255, WIFI_STATUS_NO_SHIELD = 255
} }
export enum WiFiEncryptionType { export enum WiFiEncryptionType {
@@ -15,43 +15,43 @@ export enum WiFiEncryptionType {
WIFI_AUTH_WPA_PSK = 2, WIFI_AUTH_WPA_PSK = 2,
WIFI_AUTH_WPA2_PSK = 3, WIFI_AUTH_WPA2_PSK = 3,
WIFI_AUTH_WPA_WPA2_PSK = 4, WIFI_AUTH_WPA_WPA2_PSK = 4,
WIFI_AUTH_WPA2_ENTERPRISE = 5, WIFI_AUTH_WPA2_ENTERPRISE = 5
} }
export interface NetworkStatus { export interface NetworkStatus {
status: NetworkConnectionStatus status: NetworkConnectionStatus;
local_ip: string local_ip: string;
mac_address: string mac_address: string;
rssi: number rssi: number;
ssid: string ssid: string;
bssid: string bssid: string;
channel: number channel: number;
subnet_mask: string subnet_mask: string;
gateway_ip: string gateway_ip: string;
dns_ip_1: string dns_ip_1: string;
dns_ip_2: string dns_ip_2: string;
} }
export interface NetworkSettings { export interface NetworkSettings {
ssid: string ssid: string;
password: string password: string;
hostname: string hostname: string;
static_ip_config: boolean static_ip_config: boolean;
local_ip?: string local_ip?: string;
gateway_ip?: string gateway_ip?: string;
subnet_mask?: string subnet_mask?: string;
dns_ip_1?: string dns_ip_1?: string;
dns_ip_2?: string dns_ip_2?: string;
} }
export interface WiFiNetworkList { export interface WiFiNetworkList {
networks: WiFiNetwork[] networks: WiFiNetwork[];
} }
export interface WiFiNetwork { export interface WiFiNetwork {
rssi: number rssi: number;
ssid: string ssid: string;
bssid: string bssid: string;
channel: number channel: number;
encryption_type: WiFiEncryptionType encryption_type: WiFiEncryptionType;
} }

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { NTP_SETTINGS_ENDPOINT } from '../api'; import { NTP_SETTINGS_ENDPOINT } from '../api';
import NTPSettingsForm from './NTPSettingsForm'; import NTPSettingsForm from './NTPSettingsForm';
@@ -9,7 +14,6 @@ import { NTPSettings } from './types';
type NTPSettingsControllerProps = RestControllerProps<NTPSettings>; type NTPSettingsControllerProps = RestControllerProps<NTPSettings>;
class NTPSettingsController extends Component<NTPSettingsControllerProps> { class NTPSettingsController extends Component<NTPSettingsControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -19,12 +23,11 @@ class NTPSettingsController extends Component<NTPSettingsControllerProps> {
<SectionContent title="NTP Settings" titleGutter> <SectionContent title="NTP Settings" titleGutter>
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <NTPSettingsForm {...formProps} />} render={(formProps) => <NTPSettingsForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
) );
} }
} }
export default restController(NTP_SETTINGS_ENDPOINT, NTPSettingsController); export default restController(NTP_SETTINGS_ENDPOINT, NTPSettingsController);

View File

@@ -1,10 +1,19 @@
import React from 'react'; import React from 'react';
import { TextValidator, ValidatorForm, SelectValidator } from 'react-material-ui-form-validator'; import {
TextValidator,
ValidatorForm,
SelectValidator
} from 'react-material-ui-form-validator';
import { Checkbox, MenuItem } from '@material-ui/core'; import { Checkbox, MenuItem } from '@material-ui/core';
import SaveIcon from '@material-ui/icons/Save'; import SaveIcon from '@material-ui/icons/Save';
import { RestFormProps, FormActions, FormButton, BlockFormControlLabel } from '../components'; import {
RestFormProps,
FormActions,
FormButton,
BlockFormControlLabel
} from '../components';
import { isIP, isHostname, or } from '../validators'; import { isIP, isHostname, or } from '../validators';
import { TIME_ZONES, timeZoneSelectItems, selectedTimeZone } from './TZ'; import { TIME_ZONES, timeZoneSelectItems, selectedTimeZone } from './TZ';
@@ -13,7 +22,6 @@ import { NTPSettings } from './types';
type NTPSettingsFormProps = RestFormProps<NTPSettings>; type NTPSettingsFormProps = RestFormProps<NTPSettings>;
class NTPSettingsForm extends React.Component<NTPSettingsFormProps> { class NTPSettingsForm extends React.Component<NTPSettingsFormProps> {
componentDidMount() { componentDidMount() {
ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname)); ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
} }
@@ -25,7 +33,7 @@ class NTPSettingsForm extends React.Component<NTPSettingsFormProps> {
tz_label: event.target.value, tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value] tz_format: TIME_ZONES[event.target.value]
}); });
} };
render() { render() {
const { data, handleValueChange, saveData } = this.props; const { data, handleValueChange, saveData } = this.props;
@@ -43,7 +51,10 @@ class NTPSettingsForm extends React.Component<NTPSettingsFormProps> {
/> />
<TextValidator <TextValidator
validators={['required', 'isIPOrHostname']} validators={['required', 'isIPOrHostname']}
errorMessages={['Server is required', "Not a valid IP address or hostname"]} errorMessages={[
'Server is required',
'Not a valid IP address or hostname'
]}
name="server" name="server"
label="Server" label="Server"
fullWidth fullWidth
@@ -68,7 +79,12 @@ class NTPSettingsForm extends React.Component<NTPSettingsFormProps> {
{timeZoneSelectItems()} {timeZoneSelectItems()}
</SelectValidator> </SelectValidator>
<FormActions> <FormActions>
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> <FormButton
startIcon={<SaveIcon />}
variant="contained"
color="primary"
type="submit"
>
Save Save
</FormButton> </FormButton>
</FormActions> </FormActions>

View File

@@ -1,27 +1,27 @@
import { Theme } from '@material-ui/core' import { Theme } from '@material-ui/core';
import { NTPStatus, NTPSyncStatus } from './types' import { NTPStatus, NTPSyncStatus } from './types';
export const isNtpActive = ({ status }: NTPStatus) => export const isNtpActive = ({ status }: NTPStatus) =>
status === NTPSyncStatus.NTP_ACTIVE status === NTPSyncStatus.NTP_ACTIVE;
export const ntpStatusHighlight = ({ status }: NTPStatus, theme: Theme) => { export const ntpStatusHighlight = ({ status }: NTPStatus, theme: Theme) => {
switch (status) { switch (status) {
case NTPSyncStatus.NTP_INACTIVE: case NTPSyncStatus.NTP_INACTIVE:
return theme.palette.info.main return theme.palette.info.main;
case NTPSyncStatus.NTP_ACTIVE: case NTPSyncStatus.NTP_ACTIVE:
return theme.palette.success.main return theme.palette.success.main;
default: default:
return theme.palette.error.main return theme.palette.error.main;
} }
} };
export const ntpStatus = ({ status }: NTPStatus) => { export const ntpStatus = ({ status }: NTPStatus) => {
switch (status) { switch (status) {
case NTPSyncStatus.NTP_INACTIVE: case NTPSyncStatus.NTP_INACTIVE:
return 'Inactive' return 'Inactive';
case NTPSyncStatus.NTP_ACTIVE: case NTPSyncStatus.NTP_ACTIVE:
return 'Active' return 'Active';
default: default:
return 'Unknown' return 'Unknown';
} }
} };

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { NTP_STATUS_ENDPOINT } from '../api'; import { NTP_STATUS_ENDPOINT } from '../api';
import NTPStatusForm from './NTPStatusForm'; import NTPStatusForm from './NTPStatusForm';
@@ -9,7 +14,6 @@ import { NTPStatus } from './types';
type NTPStatusControllerProps = RestControllerProps<NTPStatus>; type NTPStatusControllerProps = RestControllerProps<NTPStatus>;
class NTPStatusController extends Component<NTPStatusControllerProps> { class NTPStatusController extends Component<NTPStatusControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -19,12 +23,11 @@ class NTPStatusController extends Component<NTPStatusControllerProps> {
<SectionContent title="NTP Status"> <SectionContent title="NTP Status">
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <NTPStatusForm {...formProps} />} render={(formProps) => <NTPStatusForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
); );
} }
} }
export default restController(NTP_STATUS_ENDPOINT, NTPStatusController); export default restController(NTP_STATUS_ENDPOINT, NTPStatusController);

View File

@@ -1,8 +1,23 @@
import React, { Component, Fragment } from 'react'; import React, { Component, Fragment } from 'react';
import { WithTheme, withTheme } from '@material-ui/core/styles'; import { WithTheme, withTheme } from '@material-ui/core/styles';
import { Avatar, Divider, List, ListItem, ListItemAvatar, ListItemText, Button } from '@material-ui/core'; import {
import { Dialog, DialogTitle, DialogContent, DialogActions, Box, TextField } from '@material-ui/core'; 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 SwapVerticalCircleIcon from '@material-ui/icons/SwapVerticalCircle';
import AccessTimeIcon from '@material-ui/icons/AccessTime'; import AccessTimeIcon from '@material-ui/icons/AccessTime';
@@ -13,12 +28,22 @@ import RefreshIcon from '@material-ui/icons/Refresh';
import { RestFormProps, FormButton, HighlightAvatar } from '../components'; import { RestFormProps, FormButton, HighlightAvatar } from '../components';
import { isNtpActive, ntpStatusHighlight, ntpStatus } from './NTPStatus'; import { isNtpActive, ntpStatusHighlight, ntpStatus } from './NTPStatus';
import { formatDuration, formatDateTime, formatLocalDateTime } from './TimeFormat'; import {
formatDuration,
formatDateTime,
formatLocalDateTime
} from './TimeFormat';
import { NTPStatus, Time } from './types'; import { NTPStatus, Time } from './types';
import { redirectingAuthorizedFetch, withAuthenticatedContext, AuthenticatedContextProps } from '../authentication'; import {
redirectingAuthorizedFetch,
withAuthenticatedContext,
AuthenticatedContextProps
} from '../authentication';
import { TIME_ENDPOINT } from '../api'; import { TIME_ENDPOINT } from '../api';
type NTPStatusFormProps = RestFormProps<NTPStatus> & WithTheme & AuthenticatedContextProps; type NTPStatusFormProps = RestFormProps<NTPStatus> &
WithTheme &
AuthenticatedContextProps;
interface NTPStatusFormState { interface NTPStatusFormState {
settingTime: boolean; settingTime: boolean;
@@ -27,7 +52,6 @@ interface NTPStatusFormState {
} }
class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> { class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
constructor(props: NTPStatusFormProps) { constructor(props: NTPStatusFormProps) {
super(props); super(props);
this.state = { this.state = {
@@ -41,20 +65,20 @@ class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
this.setState({ this.setState({
localTime: event.target.value localTime: event.target.value
}); });
} };
openSetTime = () => { openSetTime = () => {
this.setState({ this.setState({
localTime: formatLocalDateTime(new Date()), localTime: formatLocalDateTime(new Date()),
settingTime: true settingTime: true
}); });
} };
closeSetTime = () => { closeSetTime = () => {
this.setState({ this.setState({
settingTime: false settingTime: false
}); });
} };
createTime = (): Time => ({ createTime = (): Time => ({
local_time: formatLocalDateTime(new Date(this.state.localTime)) local_time: formatLocalDateTime(new Date(this.state.localTime))
@@ -62,27 +86,34 @@ class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
configureTime = () => { configureTime = () => {
this.setState({ processing: true }); this.setState({ processing: true });
redirectingAuthorizedFetch(TIME_ENDPOINT, redirectingAuthorizedFetch(TIME_ENDPOINT, {
{ method: 'POST',
method: 'POST', body: JSON.stringify(this.createTime()),
body: JSON.stringify(this.createTime()), headers: {
headers: { 'Content-Type': 'application/json'
'Content-Type': 'application/json' }
} })
}) .then((response) => {
.then(response => {
if (response.status === 200) { if (response.status === 200) {
this.props.enqueueSnackbar("Time set successfully", { variant: 'success' }); this.props.enqueueSnackbar('Time set successfully', {
this.setState({ processing: false, settingTime: false }, this.props.loadData); variant: 'success'
});
this.setState(
{ processing: false, settingTime: false },
this.props.loadData
);
} else { } else {
throw Error("Error setting time, status code: " + response.status); throw Error('Error setting time, status code: ' + response.status);
} }
}) })
.catch(error => { .catch((error) => {
this.props.enqueueSnackbar(error.message || "Problem setting the time", { variant: 'error' }); this.props.enqueueSnackbar(
error.message || 'Problem setting the time',
{ variant: 'error' }
);
this.setState({ processing: false, settingTime: false }); this.setState({ processing: false, settingTime: false });
}); });
} };
renderSetTimeDialog() { renderSetTimeDialog() {
return ( return (
@@ -94,7 +125,9 @@ class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
> >
<DialogTitle>Set Time</DialogTitle> <DialogTitle>Set Time</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<Box mb={2}>Enter local date and time below to set the device's time.</Box> <Box mb={2}>
Enter local date and time below to set the device's time.
</Box>
<TextField <TextField
label="Local Time" label="Local Time"
type="datetime-local" type="datetime-local"
@@ -104,24 +137,35 @@ class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
variant="outlined" variant="outlined"
fullWidth fullWidth
InputLabelProps={{ InputLabelProps={{
shrink: true, shrink: true
}} }}
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button variant="contained" onClick={this.closeSetTime} color="secondary"> <Button
variant="contained"
onClick={this.closeSetTime}
color="secondary"
>
Cancel Cancel
</Button> </Button>
<Button startIcon={<AccessTimeIcon />} variant="contained" onClick={this.configureTime} disabled={this.state.processing} color="primary" autoFocus> <Button
startIcon={<AccessTimeIcon />}
variant="contained"
onClick={this.configureTime}
disabled={this.state.processing}
color="primary"
autoFocus
>
Set Time Set Time
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
) );
} }
render() { render() {
const { data, theme } = this.props const { data, theme } = this.props;
const me = this.props.authenticatedContext.me; const me = this.props.authenticatedContext.me;
return ( return (
<Fragment> <Fragment>
@@ -154,7 +198,10 @@ class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
<AccessTimeIcon /> <AccessTimeIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Local Time" secondary={formatDateTime(data.local_time)} /> <ListItemText
primary="Local Time"
secondary={formatDateTime(data.local_time)}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
@@ -163,7 +210,10 @@ class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
<SwapVerticalCircleIcon /> <SwapVerticalCircleIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="UTC Time" secondary={formatDateTime(data.utc_time)} /> <ListItemText
primary="UTC Time"
secondary={formatDateTime(data.utc_time)}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
<ListItem> <ListItem>
@@ -172,19 +222,32 @@ class NTPStatusForm extends Component<NTPStatusFormProps, NTPStatusFormState> {
<AvTimerIcon /> <AvTimerIcon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Uptime" secondary={formatDuration(data.uptime)} /> <ListItemText
primary="Uptime"
secondary={formatDuration(data.uptime)}
/>
</ListItem> </ListItem>
<Divider variant="inset" component="li" /> <Divider variant="inset" component="li" />
</List> </List>
<Box display="flex" flexWrap="wrap"> <Box display="flex" flexWrap="wrap">
<Box flexGrow={1} padding={1}> <Box flexGrow={1} padding={1}>
<FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}> <FormButton
startIcon={<RefreshIcon />}
variant="contained"
color="secondary"
onClick={this.props.loadData}
>
Refresh Refresh
</FormButton> </FormButton>
</Box> </Box>
{me.admin && !isNtpActive(data) && ( {me.admin && !isNtpActive(data) && (
<Box flexWrap="none" padding={1} whiteSpace="nowrap"> <Box flexWrap="none" padding={1} whiteSpace="nowrap">
<Button onClick={this.openSetTime} variant="contained" color="primary" startIcon={<AccessTimeIcon />}> <Button
onClick={this.openSetTime}
variant="contained"
color="primary"
startIcon={<AccessTimeIcon />}
>
Set Time Set Time
</Button> </Button>
</Box> </Box>

View File

@@ -1,9 +1,13 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
import { Tabs, Tab } from '@material-ui/core'; import { Tabs, Tab } from '@material-ui/core';
import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication'; import {
withAuthenticatedContext,
AuthenticatedContextProps,
AuthenticatedRoute
} from '../authentication';
import { MenuAppBar } from '../components'; import { MenuAppBar } from '../components';
import NTPStatusController from './NTPStatusController'; import NTPStatusController from './NTPStatusController';
@@ -12,8 +16,7 @@ import NTPSettingsController from './NTPSettingsController';
type NetworkTimeProps = AuthenticatedContextProps & RouteComponentProps; type NetworkTimeProps = AuthenticatedContextProps & RouteComponentProps;
class NetworkTime extends Component<NetworkTimeProps> { class NetworkTime extends Component<NetworkTimeProps> {
handleTabChange = (path: string) => {
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
this.props.history.push(path); this.props.history.push(path);
}; };
@@ -21,19 +24,34 @@ class NetworkTime extends Component<NetworkTimeProps> {
const { authenticatedContext } = this.props; const { authenticatedContext } = this.props;
return ( return (
<MenuAppBar sectionTitle="Network Time"> <MenuAppBar sectionTitle="Network Time">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> <Tabs
value={this.props.match.url}
onChange={(e, path) => this.handleTabChange(path)}
variant="fullWidth"
>
<Tab value="/ntp/status" label="NTP Status" /> <Tab value="/ntp/status" label="NTP Status" />
<Tab value="/ntp/settings" label="NTP Settings" disabled={!authenticatedContext.me.admin} /> <Tab
value="/ntp/settings"
label="NTP Settings"
disabled={!authenticatedContext.me.admin}
/>
</Tabs> </Tabs>
<Switch> <Switch>
<AuthenticatedRoute exact path="/ntp/status" component={NTPStatusController} /> <AuthenticatedRoute
<AuthenticatedRoute exact path="/ntp/settings" component={NTPSettingsController} /> exact
path="/ntp/status"
component={NTPStatusController}
/>
<AuthenticatedRoute
exact
path="/ntp/settings"
component={NTPSettingsController}
/>
<Redirect to="/ntp/status" /> <Redirect to="/ntp/status" />
</Switch> </Switch>
</MenuAppBar> </MenuAppBar>
) );
} }
} }
export default withAuthenticatedContext(NetworkTime) export default withAuthenticatedContext(NetworkTime);

View File

@@ -1,479 +1,480 @@
import React from 'react';
import MenuItem from '@material-ui/core/MenuItem'; import MenuItem from '@material-ui/core/MenuItem';
type TimeZones = { type TimeZones = {
[name: string]: string [name: string]: string;
}; };
export const TIME_ZONES: TimeZones = { export const TIME_ZONES: TimeZones = {
"Africa/Abidjan": "GMT0", 'Africa/Abidjan': 'GMT0',
"Africa/Accra": "GMT0", 'Africa/Accra': 'GMT0',
"Africa/Addis_Ababa": "EAT-3", 'Africa/Addis_Ababa': 'EAT-3',
"Africa/Algiers": "CET-1", 'Africa/Algiers': 'CET-1',
"Africa/Asmara": "EAT-3", 'Africa/Asmara': 'EAT-3',
"Africa/Bamako": "GMT0", 'Africa/Bamako': 'GMT0',
"Africa/Bangui": "WAT-1", 'Africa/Bangui': 'WAT-1',
"Africa/Banjul": "GMT0", 'Africa/Banjul': 'GMT0',
"Africa/Bissau": "GMT0", 'Africa/Bissau': 'GMT0',
"Africa/Blantyre": "CAT-2", 'Africa/Blantyre': 'CAT-2',
"Africa/Brazzaville": "WAT-1", 'Africa/Brazzaville': 'WAT-1',
"Africa/Bujumbura": "CAT-2", 'Africa/Bujumbura': 'CAT-2',
"Africa/Cairo": "EET-2", 'Africa/Cairo': 'EET-2',
"Africa/Casablanca": "UNK-1", 'Africa/Casablanca': 'UNK-1',
"Africa/Ceuta": "CET-1CEST,M3.5.0,M10.5.0/3", 'Africa/Ceuta': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Africa/Conakry": "GMT0", 'Africa/Conakry': 'GMT0',
"Africa/Dakar": "GMT0", 'Africa/Dakar': 'GMT0',
"Africa/Dar_es_Salaam": "EAT-3", 'Africa/Dar_es_Salaam': 'EAT-3',
"Africa/Djibouti": "EAT-3", 'Africa/Djibouti': 'EAT-3',
"Africa/Douala": "WAT-1", 'Africa/Douala': 'WAT-1',
"Africa/El_Aaiun": "UNK-1", 'Africa/El_Aaiun': 'UNK-1',
"Africa/Freetown": "GMT0", 'Africa/Freetown': 'GMT0',
"Africa/Gaborone": "CAT-2", 'Africa/Gaborone': 'CAT-2',
"Africa/Harare": "CAT-2", 'Africa/Harare': 'CAT-2',
"Africa/Johannesburg": "SAST-2", 'Africa/Johannesburg': 'SAST-2',
"Africa/Juba": "EAT-3", 'Africa/Juba': 'EAT-3',
"Africa/Kampala": "EAT-3", 'Africa/Kampala': 'EAT-3',
"Africa/Khartoum": "CAT-2", 'Africa/Khartoum': 'CAT-2',
"Africa/Kigali": "CAT-2", 'Africa/Kigali': 'CAT-2',
"Africa/Kinshasa": "WAT-1", 'Africa/Kinshasa': 'WAT-1',
"Africa/Lagos": "WAT-1", 'Africa/Lagos': 'WAT-1',
"Africa/Libreville": "WAT-1", 'Africa/Libreville': 'WAT-1',
"Africa/Lome": "GMT0", 'Africa/Lome': 'GMT0',
"Africa/Luanda": "WAT-1", 'Africa/Luanda': 'WAT-1',
"Africa/Lubumbashi": "CAT-2", 'Africa/Lubumbashi': 'CAT-2',
"Africa/Lusaka": "CAT-2", 'Africa/Lusaka': 'CAT-2',
"Africa/Malabo": "WAT-1", 'Africa/Malabo': 'WAT-1',
"Africa/Maputo": "CAT-2", 'Africa/Maputo': 'CAT-2',
"Africa/Maseru": "SAST-2", 'Africa/Maseru': 'SAST-2',
"Africa/Mbabane": "SAST-2", 'Africa/Mbabane': 'SAST-2',
"Africa/Mogadishu": "EAT-3", 'Africa/Mogadishu': 'EAT-3',
"Africa/Monrovia": "GMT0", 'Africa/Monrovia': 'GMT0',
"Africa/Nairobi": "EAT-3", 'Africa/Nairobi': 'EAT-3',
"Africa/Ndjamena": "WAT-1", 'Africa/Ndjamena': 'WAT-1',
"Africa/Niamey": "WAT-1", 'Africa/Niamey': 'WAT-1',
"Africa/Nouakchott": "GMT0", 'Africa/Nouakchott': 'GMT0',
"Africa/Ouagadougou": "GMT0", 'Africa/Ouagadougou': 'GMT0',
"Africa/Porto-Novo": "WAT-1", 'Africa/Porto-Novo': 'WAT-1',
"Africa/Sao_Tome": "GMT0", 'Africa/Sao_Tome': 'GMT0',
"Africa/Tripoli": "EET-2", 'Africa/Tripoli': 'EET-2',
"Africa/Tunis": "CET-1", 'Africa/Tunis': 'CET-1',
"Africa/Windhoek": "CAT-2", 'Africa/Windhoek': 'CAT-2',
"America/Adak": "HST10HDT,M3.2.0,M11.1.0", 'America/Adak': 'HST10HDT,M3.2.0,M11.1.0',
"America/Anchorage": "AKST9AKDT,M3.2.0,M11.1.0", 'America/Anchorage': 'AKST9AKDT,M3.2.0,M11.1.0',
"America/Anguilla": "AST4", 'America/Anguilla': 'AST4',
"America/Antigua": "AST4", 'America/Antigua': 'AST4',
"America/Araguaina": "UNK3", 'America/Araguaina': 'UNK3',
"America/Argentina/Buenos_Aires": "UNK3", 'America/Argentina/Buenos_Aires': 'UNK3',
"America/Argentina/Catamarca": "UNK3", 'America/Argentina/Catamarca': 'UNK3',
"America/Argentina/Cordoba": "UNK3", 'America/Argentina/Cordoba': 'UNK3',
"America/Argentina/Jujuy": "UNK3", 'America/Argentina/Jujuy': 'UNK3',
"America/Argentina/La_Rioja": "UNK3", 'America/Argentina/La_Rioja': 'UNK3',
"America/Argentina/Mendoza": "UNK3", 'America/Argentina/Mendoza': 'UNK3',
"America/Argentina/Rio_Gallegos": "UNK3", 'America/Argentina/Rio_Gallegos': 'UNK3',
"America/Argentina/Salta": "UNK3", 'America/Argentina/Salta': 'UNK3',
"America/Argentina/San_Juan": "UNK3", 'America/Argentina/San_Juan': 'UNK3',
"America/Argentina/San_Luis": "UNK3", 'America/Argentina/San_Luis': 'UNK3',
"America/Argentina/Tucuman": "UNK3", 'America/Argentina/Tucuman': 'UNK3',
"America/Argentina/Ushuaia": "UNK3", 'America/Argentina/Ushuaia': 'UNK3',
"America/Aruba": "AST4", 'America/Aruba': 'AST4',
"America/Asuncion": "UNK4UNK,M10.1.0/0,M3.4.0/0", 'America/Asuncion': 'UNK4UNK,M10.1.0/0,M3.4.0/0',
"America/Atikokan": "EST5", 'America/Atikokan': 'EST5',
"America/Bahia": "UNK3", 'America/Bahia': 'UNK3',
"America/Bahia_Banderas": "CST6CDT,M4.1.0,M10.5.0", 'America/Bahia_Banderas': 'CST6CDT,M4.1.0,M10.5.0',
"America/Barbados": "AST4", 'America/Barbados': 'AST4',
"America/Belem": "UNK3", 'America/Belem': 'UNK3',
"America/Belize": "CST6", 'America/Belize': 'CST6',
"America/Blanc-Sablon": "AST4", 'America/Blanc-Sablon': 'AST4',
"America/Boa_Vista": "UNK4", 'America/Boa_Vista': 'UNK4',
"America/Bogota": "UNK5", 'America/Bogota': 'UNK5',
"America/Boise": "MST7MDT,M3.2.0,M11.1.0", 'America/Boise': 'MST7MDT,M3.2.0,M11.1.0',
"America/Cambridge_Bay": "MST7MDT,M3.2.0,M11.1.0", 'America/Cambridge_Bay': 'MST7MDT,M3.2.0,M11.1.0',
"America/Campo_Grande": "UNK4", 'America/Campo_Grande': 'UNK4',
"America/Cancun": "EST5", 'America/Cancun': 'EST5',
"America/Caracas": "UNK4", 'America/Caracas': 'UNK4',
"America/Cayenne": "UNK3", 'America/Cayenne': 'UNK3',
"America/Cayman": "EST5", 'America/Cayman': 'EST5',
"America/Chicago": "CST6CDT,M3.2.0,M11.1.0", 'America/Chicago': 'CST6CDT,M3.2.0,M11.1.0',
"America/Chihuahua": "MST7MDT,M4.1.0,M10.5.0", 'America/Chihuahua': 'MST7MDT,M4.1.0,M10.5.0',
"America/Costa_Rica": "CST6", 'America/Costa_Rica': 'CST6',
"America/Creston": "MST7", 'America/Creston': 'MST7',
"America/Cuiaba": "UNK4", 'America/Cuiaba': 'UNK4',
"America/Curacao": "AST4", 'America/Curacao': 'AST4',
"America/Danmarkshavn": "GMT0", 'America/Danmarkshavn': 'GMT0',
"America/Dawson": "MST7", 'America/Dawson': 'MST7',
"America/Dawson_Creek": "MST7", 'America/Dawson_Creek': 'MST7',
"America/Denver": "MST7MDT,M3.2.0,M11.1.0", 'America/Denver': 'MST7MDT,M3.2.0,M11.1.0',
"America/Detroit": "EST5EDT,M3.2.0,M11.1.0", 'America/Detroit': 'EST5EDT,M3.2.0,M11.1.0',
"America/Dominica": "AST4", 'America/Dominica': 'AST4',
"America/Edmonton": "MST7MDT,M3.2.0,M11.1.0", 'America/Edmonton': 'MST7MDT,M3.2.0,M11.1.0',
"America/Eirunepe": "UNK5", 'America/Eirunepe': 'UNK5',
"America/El_Salvador": "CST6", 'America/El_Salvador': 'CST6',
"America/Fort_Nelson": "MST7", 'America/Fort_Nelson': 'MST7',
"America/Fortaleza": "UNK3", 'America/Fortaleza': 'UNK3',
"America/Glace_Bay": "AST4ADT,M3.2.0,M11.1.0", 'America/Glace_Bay': 'AST4ADT,M3.2.0,M11.1.0',
"America/Godthab": "UNK3UNK,M3.5.0/-2,M10.5.0/-1", 'America/Godthab': 'UNK3UNK,M3.5.0/-2,M10.5.0/-1',
"America/Goose_Bay": "AST4ADT,M3.2.0,M11.1.0", 'America/Goose_Bay': 'AST4ADT,M3.2.0,M11.1.0',
"America/Grand_Turk": "EST5EDT,M3.2.0,M11.1.0", 'America/Grand_Turk': 'EST5EDT,M3.2.0,M11.1.0',
"America/Grenada": "AST4", 'America/Grenada': 'AST4',
"America/Guadeloupe": "AST4", 'America/Guadeloupe': 'AST4',
"America/Guatemala": "CST6", 'America/Guatemala': 'CST6',
"America/Guayaquil": "UNK5", 'America/Guayaquil': 'UNK5',
"America/Guyana": "UNK4", 'America/Guyana': 'UNK4',
"America/Halifax": "AST4ADT,M3.2.0,M11.1.0", 'America/Halifax': 'AST4ADT,M3.2.0,M11.1.0',
"America/Havana": "CST5CDT,M3.2.0/0,M11.1.0/1", 'America/Havana': 'CST5CDT,M3.2.0/0,M11.1.0/1',
"America/Hermosillo": "MST7", 'America/Hermosillo': 'MST7',
"America/Indiana/Indianapolis": "EST5EDT,M3.2.0,M11.1.0", 'America/Indiana/Indianapolis': 'EST5EDT,M3.2.0,M11.1.0',
"America/Indiana/Knox": "CST6CDT,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/Marengo': 'EST5EDT,M3.2.0,M11.1.0',
"America/Indiana/Petersburg": "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/Tell_City': 'CST6CDT,M3.2.0,M11.1.0',
"America/Indiana/Vevay": "EST5EDT,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/Vincennes': 'EST5EDT,M3.2.0,M11.1.0',
"America/Indiana/Winamac": "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/Inuvik': 'MST7MDT,M3.2.0,M11.1.0',
"America/Iqaluit": "EST5EDT,M3.2.0,M11.1.0", 'America/Iqaluit': 'EST5EDT,M3.2.0,M11.1.0',
"America/Jamaica": "EST5", 'America/Jamaica': 'EST5',
"America/Juneau": "AKST9AKDT,M3.2.0,M11.1.0", 'America/Juneau': 'AKST9AKDT,M3.2.0,M11.1.0',
"America/Kentucky/Louisville": "EST5EDT,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/Kentucky/Monticello': 'EST5EDT,M3.2.0,M11.1.0',
"America/Kralendijk": "AST4", 'America/Kralendijk': 'AST4',
"America/La_Paz": "UNK4", 'America/La_Paz': 'UNK4',
"America/Lima": "UNK5", 'America/Lima': 'UNK5',
"America/Los_Angeles": "PST8PDT,M3.2.0,M11.1.0", 'America/Los_Angeles': 'PST8PDT,M3.2.0,M11.1.0',
"America/Lower_Princes": "AST4", 'America/Lower_Princes': 'AST4',
"America/Maceio": "UNK3", 'America/Maceio': 'UNK3',
"America/Managua": "CST6", 'America/Managua': 'CST6',
"America/Manaus": "UNK4", 'America/Manaus': 'UNK4',
"America/Marigot": "AST4", 'America/Marigot': 'AST4',
"America/Martinique": "AST4", 'America/Martinique': 'AST4',
"America/Matamoros": "CST6CDT,M3.2.0,M11.1.0", 'America/Matamoros': 'CST6CDT,M3.2.0,M11.1.0',
"America/Mazatlan": "MST7MDT,M4.1.0,M10.5.0", 'America/Mazatlan': 'MST7MDT,M4.1.0,M10.5.0',
"America/Menominee": "CST6CDT,M3.2.0,M11.1.0", 'America/Menominee': 'CST6CDT,M3.2.0,M11.1.0',
"America/Merida": "CST6CDT,M4.1.0,M10.5.0", 'America/Merida': 'CST6CDT,M4.1.0,M10.5.0',
"America/Metlakatla": "AKST9AKDT,M3.2.0,M11.1.0", 'America/Metlakatla': 'AKST9AKDT,M3.2.0,M11.1.0',
"America/Mexico_City": "CST6CDT,M4.1.0,M10.5.0", 'America/Mexico_City': 'CST6CDT,M4.1.0,M10.5.0',
"America/Miquelon": "UNK3UNK,M3.2.0,M11.1.0", 'America/Miquelon': 'UNK3UNK,M3.2.0,M11.1.0',
"America/Moncton": "AST4ADT,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/Monterrey': 'CST6CDT,M4.1.0,M10.5.0',
"America/Montevideo": "UNK3", 'America/Montevideo': 'UNK3',
"America/Montreal": "EST5EDT,M3.2.0,M11.1.0", 'America/Montreal': 'EST5EDT,M3.2.0,M11.1.0',
"America/Montserrat": "AST4", 'America/Montserrat': 'AST4',
"America/Nassau": "EST5EDT,M3.2.0,M11.1.0", 'America/Nassau': 'EST5EDT,M3.2.0,M11.1.0',
"America/New_York": "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/Nipigon': 'EST5EDT,M3.2.0,M11.1.0',
"America/Nome": "AKST9AKDT,M3.2.0,M11.1.0", 'America/Nome': 'AKST9AKDT,M3.2.0,M11.1.0',
"America/Noronha": "UNK2", 'America/Noronha': 'UNK2',
"America/North_Dakota/Beulah": "CST6CDT,M3.2.0,M11.1.0", '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/Center': 'CST6CDT,M3.2.0,M11.1.0',
"America/North_Dakota/New_Salem": "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/Ojinaga': 'MST7MDT,M3.2.0,M11.1.0',
"America/Panama": "EST5", 'America/Panama': 'EST5',
"America/Pangnirtung": "EST5EDT,M3.2.0,M11.1.0", 'America/Pangnirtung': 'EST5EDT,M3.2.0,M11.1.0',
"America/Paramaribo": "UNK3", 'America/Paramaribo': 'UNK3',
"America/Phoenix": "MST7", 'America/Phoenix': 'MST7',
"America/Port-au-Prince": "EST5EDT,M3.2.0,M11.1.0", 'America/Port-au-Prince': 'EST5EDT,M3.2.0,M11.1.0',
"America/Port_of_Spain": "AST4", 'America/Port_of_Spain': 'AST4',
"America/Porto_Velho": "UNK4", 'America/Porto_Velho': 'UNK4',
"America/Puerto_Rico": "AST4", 'America/Puerto_Rico': 'AST4',
"America/Punta_Arenas": "UNK3", 'America/Punta_Arenas': 'UNK3',
"America/Rainy_River": "CST6CDT,M3.2.0,M11.1.0", 'America/Rainy_River': 'CST6CDT,M3.2.0,M11.1.0',
"America/Rankin_Inlet": "CST6CDT,M3.2.0,M11.1.0", 'America/Rankin_Inlet': 'CST6CDT,M3.2.0,M11.1.0',
"America/Recife": "UNK3", 'America/Recife': 'UNK3',
"America/Regina": "CST6", 'America/Regina': 'CST6',
"America/Resolute": "CST6CDT,M3.2.0,M11.1.0", 'America/Resolute': 'CST6CDT,M3.2.0,M11.1.0',
"America/Rio_Branco": "UNK5", 'America/Rio_Branco': 'UNK5',
"America/Santarem": "UNK3", 'America/Santarem': 'UNK3',
"America/Santiago": "UNK4UNK,M9.1.6/24,M4.1.6/24", 'America/Santiago': 'UNK4UNK,M9.1.6/24,M4.1.6/24',
"America/Santo_Domingo": "AST4", 'America/Santo_Domingo': 'AST4',
"America/Sao_Paulo": "UNK3", 'America/Sao_Paulo': 'UNK3',
"America/Scoresbysund": "UNK1UNK,M3.5.0/0,M10.5.0/1", 'America/Scoresbysund': 'UNK1UNK,M3.5.0/0,M10.5.0/1',
"America/Sitka": "AKST9AKDT,M3.2.0,M11.1.0", 'America/Sitka': 'AKST9AKDT,M3.2.0,M11.1.0',
"America/St_Barthelemy": "AST4", 'America/St_Barthelemy': 'AST4',
"America/St_Johns": "NST3:30NDT,M3.2.0,M11.1.0", 'America/St_Johns': 'NST3:30NDT,M3.2.0,M11.1.0',
"America/St_Kitts": "AST4", 'America/St_Kitts': 'AST4',
"America/St_Lucia": "AST4", 'America/St_Lucia': 'AST4',
"America/St_Thomas": "AST4", 'America/St_Thomas': 'AST4',
"America/St_Vincent": "AST4", 'America/St_Vincent': 'AST4',
"America/Swift_Current": "CST6", 'America/Swift_Current': 'CST6',
"America/Tegucigalpa": "CST6", 'America/Tegucigalpa': 'CST6',
"America/Thule": "AST4ADT,M3.2.0,M11.1.0", 'America/Thule': 'AST4ADT,M3.2.0,M11.1.0',
"America/Thunder_Bay": "EST5EDT,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/Tijuana': 'PST8PDT,M3.2.0,M11.1.0',
"America/Toronto": "EST5EDT,M3.2.0,M11.1.0", 'America/Toronto': 'EST5EDT,M3.2.0,M11.1.0',
"America/Tortola": "AST4", 'America/Tortola': 'AST4',
"America/Vancouver": "PST8PDT,M3.2.0,M11.1.0", 'America/Vancouver': 'PST8PDT,M3.2.0,M11.1.0',
"America/Whitehorse": "MST7", 'America/Whitehorse': 'MST7',
"America/Winnipeg": "CST6CDT,M3.2.0,M11.1.0", 'America/Winnipeg': 'CST6CDT,M3.2.0,M11.1.0',
"America/Yakutat": "AKST9AKDT,M3.2.0,M11.1.0", 'America/Yakutat': 'AKST9AKDT,M3.2.0,M11.1.0',
"America/Yellowknife": "MST7MDT,M3.2.0,M11.1.0", 'America/Yellowknife': 'MST7MDT,M3.2.0,M11.1.0',
"Antarctica/Casey": "UNK-8", 'Antarctica/Casey': 'UNK-8',
"Antarctica/Davis": "UNK-7", 'Antarctica/Davis': 'UNK-7',
"Antarctica/DumontDUrville": "UNK-10", 'Antarctica/DumontDUrville': 'UNK-10',
"Antarctica/Macquarie": "UNK-11", 'Antarctica/Macquarie': 'UNK-11',
"Antarctica/Mawson": "UNK-5", 'Antarctica/Mawson': 'UNK-5',
"Antarctica/McMurdo": "NZST-12NZDT,M9.5.0,M4.1.0/3", 'Antarctica/McMurdo': 'NZST-12NZDT,M9.5.0,M4.1.0/3',
"Antarctica/Palmer": "UNK3", 'Antarctica/Palmer': 'UNK3',
"Antarctica/Rothera": "UNK3", 'Antarctica/Rothera': 'UNK3',
"Antarctica/Syowa": "UNK-3", 'Antarctica/Syowa': 'UNK-3',
"Antarctica/Troll": "UNK0UNK-2,M3.5.0/1,M10.5.0/3", 'Antarctica/Troll': 'UNK0UNK-2,M3.5.0/1,M10.5.0/3',
"Antarctica/Vostok": "UNK-6", 'Antarctica/Vostok': 'UNK-6',
"Arctic/Longyearbyen": "CET-1CEST,M3.5.0,M10.5.0/3", 'Arctic/Longyearbyen': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Asia/Aden": "UNK-3", 'Asia/Aden': 'UNK-3',
"Asia/Almaty": "UNK-6", 'Asia/Almaty': 'UNK-6',
"Asia/Amman": "EET-2EEST,M3.5.4/24,M10.5.5/1", 'Asia/Amman': 'EET-2EEST,M3.5.4/24,M10.5.5/1',
"Asia/Anadyr": "UNK-12", 'Asia/Anadyr': 'UNK-12',
"Asia/Aqtau": "UNK-5", 'Asia/Aqtau': 'UNK-5',
"Asia/Aqtobe": "UNK-5", 'Asia/Aqtobe': 'UNK-5',
"Asia/Ashgabat": "UNK-5", 'Asia/Ashgabat': 'UNK-5',
"Asia/Atyrau": "UNK-5", 'Asia/Atyrau': 'UNK-5',
"Asia/Baghdad": "UNK-3", 'Asia/Baghdad': 'UNK-3',
"Asia/Bahrain": "UNK-3", 'Asia/Bahrain': 'UNK-3',
"Asia/Baku": "UNK-4", 'Asia/Baku': 'UNK-4',
"Asia/Bangkok": "UNK-7", 'Asia/Bangkok': 'UNK-7',
"Asia/Barnaul": "UNK-7", 'Asia/Barnaul': 'UNK-7',
"Asia/Beirut": "EET-2EEST,M3.5.0/0,M10.5.0/0", 'Asia/Beirut': 'EET-2EEST,M3.5.0/0,M10.5.0/0',
"Asia/Bishkek": "UNK-6", 'Asia/Bishkek': 'UNK-6',
"Asia/Brunei": "UNK-8", 'Asia/Brunei': 'UNK-8',
"Asia/Chita": "UNK-9", 'Asia/Chita': 'UNK-9',
"Asia/Choibalsan": "UNK-8", 'Asia/Choibalsan': 'UNK-8',
"Asia/Colombo": "UNK-5:30", 'Asia/Colombo': 'UNK-5:30',
"Asia/Damascus": "EET-2EEST,M3.5.5/0,M10.5.5/0", 'Asia/Damascus': 'EET-2EEST,M3.5.5/0,M10.5.5/0',
"Asia/Dhaka": "UNK-6", 'Asia/Dhaka': 'UNK-6',
"Asia/Dili": "UNK-9", 'Asia/Dili': 'UNK-9',
"Asia/Dubai": "UNK-4", 'Asia/Dubai': 'UNK-4',
"Asia/Dushanbe": "UNK-5", 'Asia/Dushanbe': 'UNK-5',
"Asia/Famagusta": "EET-2EEST,M3.5.0/3,M10.5.0/4", '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/Gaza': 'EET-2EEST,M3.5.5/0,M10.5.6/1',
"Asia/Hebron": "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/Ho_Chi_Minh': 'UNK-7',
"Asia/Hong_Kong": "HKT-8", 'Asia/Hong_Kong': 'HKT-8',
"Asia/Hovd": "UNK-7", 'Asia/Hovd': 'UNK-7',
"Asia/Irkutsk": "UNK-8", 'Asia/Irkutsk': 'UNK-8',
"Asia/Jakarta": "WIB-7", 'Asia/Jakarta': 'WIB-7',
"Asia/Jayapura": "WIT-9", 'Asia/Jayapura': 'WIT-9',
"Asia/Jerusalem": "IST-2IDT,M3.4.4/26,M10.5.0", 'Asia/Jerusalem': 'IST-2IDT,M3.4.4/26,M10.5.0',
"Asia/Kabul": "UNK-4:30", 'Asia/Kabul': 'UNK-4:30',
"Asia/Kamchatka": "UNK-12", 'Asia/Kamchatka': 'UNK-12',
"Asia/Karachi": "PKT-5", 'Asia/Karachi': 'PKT-5',
"Asia/Kathmandu": "UNK-5:45", 'Asia/Kathmandu': 'UNK-5:45',
"Asia/Khandyga": "UNK-9", 'Asia/Khandyga': 'UNK-9',
"Asia/Kolkata": "IST-5:30", 'Asia/Kolkata': 'IST-5:30',
"Asia/Krasnoyarsk": "UNK-7", 'Asia/Krasnoyarsk': 'UNK-7',
"Asia/Kuala_Lumpur": "UNK-8", 'Asia/Kuala_Lumpur': 'UNK-8',
"Asia/Kuching": "UNK-8", 'Asia/Kuching': 'UNK-8',
"Asia/Kuwait": "UNK-3", 'Asia/Kuwait': 'UNK-3',
"Asia/Macau": "CST-8", 'Asia/Macau': 'CST-8',
"Asia/Magadan": "UNK-11", 'Asia/Magadan': 'UNK-11',
"Asia/Makassar": "WITA-8", 'Asia/Makassar': 'WITA-8',
"Asia/Manila": "PST-8", 'Asia/Manila': 'PST-8',
"Asia/Muscat": "UNK-4", 'Asia/Muscat': 'UNK-4',
"Asia/Nicosia": "EET-2EEST,M3.5.0/3,M10.5.0/4", 'Asia/Nicosia': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
"Asia/Novokuznetsk": "UNK-7", 'Asia/Novokuznetsk': 'UNK-7',
"Asia/Novosibirsk": "UNK-7", 'Asia/Novosibirsk': 'UNK-7',
"Asia/Omsk": "UNK-6", 'Asia/Omsk': 'UNK-6',
"Asia/Oral": "UNK-5", 'Asia/Oral': 'UNK-5',
"Asia/Phnom_Penh": "UNK-7", 'Asia/Phnom_Penh': 'UNK-7',
"Asia/Pontianak": "WIB-7", 'Asia/Pontianak': 'WIB-7',
"Asia/Pyongyang": "KST-9", 'Asia/Pyongyang': 'KST-9',
"Asia/Qatar": "UNK-3", 'Asia/Qatar': 'UNK-3',
"Asia/Qyzylorda": "UNK-5", 'Asia/Qyzylorda': 'UNK-5',
"Asia/Riyadh": "UNK-3", 'Asia/Riyadh': 'UNK-3',
"Asia/Sakhalin": "UNK-11", 'Asia/Sakhalin': 'UNK-11',
"Asia/Samarkand": "UNK-5", 'Asia/Samarkand': 'UNK-5',
"Asia/Seoul": "KST-9", 'Asia/Seoul': 'KST-9',
"Asia/Shanghai": "CST-8", 'Asia/Shanghai': 'CST-8',
"Asia/Singapore": "UNK-8", 'Asia/Singapore': 'UNK-8',
"Asia/Srednekolymsk": "UNK-11", 'Asia/Srednekolymsk': 'UNK-11',
"Asia/Taipei": "CST-8", 'Asia/Taipei': 'CST-8',
"Asia/Tashkent": "UNK-5", 'Asia/Tashkent': 'UNK-5',
"Asia/Tbilisi": "UNK-4", 'Asia/Tbilisi': 'UNK-4',
"Asia/Tehran": "UNK-3:30UNK,J79/24,J263/24", 'Asia/Tehran': 'UNK-3:30UNK,J79/24,J263/24',
"Asia/Thimphu": "UNK-6", 'Asia/Thimphu': 'UNK-6',
"Asia/Tokyo": "JST-9", 'Asia/Tokyo': 'JST-9',
"Asia/Tomsk": "UNK-7", 'Asia/Tomsk': 'UNK-7',
"Asia/Ulaanbaatar": "UNK-8", 'Asia/Ulaanbaatar': 'UNK-8',
"Asia/Urumqi": "UNK-6", 'Asia/Urumqi': 'UNK-6',
"Asia/Ust-Nera": "UNK-10", 'Asia/Ust-Nera': 'UNK-10',
"Asia/Vientiane": "UNK-7", 'Asia/Vientiane': 'UNK-7',
"Asia/Vladivostok": "UNK-10", 'Asia/Vladivostok': 'UNK-10',
"Asia/Yakutsk": "UNK-9", 'Asia/Yakutsk': 'UNK-9',
"Asia/Yangon": "UNK-6:30", 'Asia/Yangon': 'UNK-6:30',
"Asia/Yekaterinburg": "UNK-5", 'Asia/Yekaterinburg': 'UNK-5',
"Asia/Yerevan": "UNK-4", 'Asia/Yerevan': 'UNK-4',
"Atlantic/Azores": "UNK1UNK,M3.5.0/0,M10.5.0/1", 'Atlantic/Azores': 'UNK1UNK,M3.5.0/0,M10.5.0/1',
"Atlantic/Bermuda": "AST4ADT,M3.2.0,M11.1.0", 'Atlantic/Bermuda': 'AST4ADT,M3.2.0,M11.1.0',
"Atlantic/Canary": "WET0WEST,M3.5.0/1,M10.5.0", 'Atlantic/Canary': 'WET0WEST,M3.5.0/1,M10.5.0',
"Atlantic/Cape_Verde": "UNK1", 'Atlantic/Cape_Verde': 'UNK1',
"Atlantic/Faroe": "WET0WEST,M3.5.0/1,M10.5.0", 'Atlantic/Faroe': 'WET0WEST,M3.5.0/1,M10.5.0',
"Atlantic/Madeira": "WET0WEST,M3.5.0/1,M10.5.0", 'Atlantic/Madeira': 'WET0WEST,M3.5.0/1,M10.5.0',
"Atlantic/Reykjavik": "GMT0", 'Atlantic/Reykjavik': 'GMT0',
"Atlantic/South_Georgia": "UNK2", 'Atlantic/South_Georgia': 'UNK2',
"Atlantic/St_Helena": "GMT0", 'Atlantic/St_Helena': 'GMT0',
"Atlantic/Stanley": "UNK3", 'Atlantic/Stanley': 'UNK3',
"Australia/Adelaide": "ACST-9:30ACDT,M10.1.0,M4.1.0/3", 'Australia/Adelaide': 'ACST-9:30ACDT,M10.1.0,M4.1.0/3',
"Australia/Brisbane": "AEST-10", 'Australia/Brisbane': 'AEST-10',
"Australia/Broken_Hill": "ACST-9:30ACDT,M10.1.0,M4.1.0/3", '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/Currie': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
"Australia/Darwin": "ACST-9:30", 'Australia/Darwin': 'ACST-9:30',
"Australia/Eucla": "UNK-8:45", 'Australia/Eucla': 'UNK-8:45',
"Australia/Hobart": "AEST-10AEDT,M10.1.0,M4.1.0/3", 'Australia/Hobart': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
"Australia/Lindeman": "AEST-10", 'Australia/Lindeman': 'AEST-10',
"Australia/Lord_Howe": "UNK-10:30UNK-11,M10.1.0,M4.1.0", '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/Melbourne': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
"Australia/Perth": "AWST-8", 'Australia/Perth': 'AWST-8',
"Australia/Sydney": "AEST-10AEDT,M10.1.0,M4.1.0/3", 'Australia/Sydney': 'AEST-10AEDT,M10.1.0,M4.1.0/3',
"Etc/GMT": "GMT0", 'Etc/GMT': 'GMT0',
"Etc/GMT+0": "GMT0", 'Etc/GMT+0': 'GMT0',
"Etc/GMT+1": "UNK1", 'Etc/GMT+1': 'UNK1',
"Etc/GMT+10": "UNK10", 'Etc/GMT+10': 'UNK10',
"Etc/GMT+11": "UNK11", 'Etc/GMT+11': 'UNK11',
"Etc/GMT+12": "UNK12", 'Etc/GMT+12': 'UNK12',
"Etc/GMT+2": "UNK2", 'Etc/GMT+2': 'UNK2',
"Etc/GMT+3": "UNK3", 'Etc/GMT+3': 'UNK3',
"Etc/GMT+4": "UNK4", 'Etc/GMT+4': 'UNK4',
"Etc/GMT+5": "UNK5", 'Etc/GMT+5': 'UNK5',
"Etc/GMT+6": "UNK6", 'Etc/GMT+6': 'UNK6',
"Etc/GMT+7": "UNK7", 'Etc/GMT+7': 'UNK7',
"Etc/GMT+8": "UNK8", 'Etc/GMT+8': 'UNK8',
"Etc/GMT+9": "UNK9", 'Etc/GMT+9': 'UNK9',
"Etc/GMT-0": "GMT0", 'Etc/GMT-0': 'GMT0',
"Etc/GMT-1": "UNK-1", 'Etc/GMT-1': 'UNK-1',
"Etc/GMT-10": "UNK-10", 'Etc/GMT-10': 'UNK-10',
"Etc/GMT-11": "UNK-11", 'Etc/GMT-11': 'UNK-11',
"Etc/GMT-12": "UNK-12", 'Etc/GMT-12': 'UNK-12',
"Etc/GMT-13": "UNK-13", 'Etc/GMT-13': 'UNK-13',
"Etc/GMT-14": "UNK-14", 'Etc/GMT-14': 'UNK-14',
"Etc/GMT-2": "UNK-2", 'Etc/GMT-2': 'UNK-2',
"Etc/GMT-3": "UNK-3", 'Etc/GMT-3': 'UNK-3',
"Etc/GMT-4": "UNK-4", 'Etc/GMT-4': 'UNK-4',
"Etc/GMT-5": "UNK-5", 'Etc/GMT-5': 'UNK-5',
"Etc/GMT-6": "UNK-6", 'Etc/GMT-6': 'UNK-6',
"Etc/GMT-7": "UNK-7", 'Etc/GMT-7': 'UNK-7',
"Etc/GMT-8": "UNK-8", 'Etc/GMT-8': 'UNK-8',
"Etc/GMT-9": "UNK-9", 'Etc/GMT-9': 'UNK-9',
"Etc/GMT0": "GMT0", 'Etc/GMT0': 'GMT0',
"Etc/Greenwich": "GMT0", 'Etc/Greenwich': 'GMT0',
"Etc/UCT": "UTC0", 'Etc/UCT': 'UTC0',
"Etc/UTC": "UTC0", 'Etc/UTC': 'UTC0',
"Etc/Universal": "UTC0", 'Etc/Universal': 'UTC0',
"Etc/Zulu": "UTC0", 'Etc/Zulu': 'UTC0',
"Europe/Amsterdam": "CET-1CEST,M3.5.0,M10.5.0/3", 'Europe/Amsterdam': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Andorra": "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/Astrakhan': 'UNK-4',
"Europe/Athens": "EET-2EEST,M3.5.0/3,M10.5.0/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/Belgrade': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Berlin": "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/Bratislava': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Brussels": "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/Bucharest': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
"Europe/Budapest": "CET-1CEST,M3.5.0,M10.5.0/3", 'Europe/Budapest': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Busingen": "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/Chisinau': 'EET-2EEST,M3.5.0,M10.5.0/3',
"Europe/Copenhagen": "CET-1CEST,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/Dublin': 'IST-1GMT0,M10.5.0,M3.5.0/1',
"Europe/Gibraltar": "CET-1CEST,M3.5.0,M10.5.0/3", 'Europe/Gibraltar': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Guernsey": "GMT0BST,M3.5.0/1,M10.5.0", 'Europe/Guernsey': 'GMT0BST,M3.5.0/1,M10.5.0',
"Europe/Helsinki": "EET-2EEST,M3.5.0/3,M10.5.0/4", '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/Isle_of_Man': 'GMT0BST,M3.5.0/1,M10.5.0',
"Europe/Istanbul": "UNK-3", 'Europe/Istanbul': 'UNK-3',
"Europe/Jersey": "GMT0BST,M3.5.0/1,M10.5.0", 'Europe/Jersey': 'GMT0BST,M3.5.0/1,M10.5.0',
"Europe/Kaliningrad": "EET-2", 'Europe/Kaliningrad': 'EET-2',
"Europe/Kiev": "EET-2EEST,M3.5.0/3,M10.5.0/4", 'Europe/Kiev': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
"Europe/Kirov": "UNK-3", 'Europe/Kirov': 'UNK-3',
"Europe/Lisbon": "WET0WEST,M3.5.0/1,M10.5.0", 'Europe/Lisbon': 'WET0WEST,M3.5.0/1,M10.5.0',
"Europe/Ljubljana": "CET-1CEST,M3.5.0,M10.5.0/3", 'Europe/Ljubljana': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/London": "GMT0BST,M3.5.0/1,M10.5.0", 'Europe/London': 'GMT0BST,M3.5.0/1,M10.5.0',
"Europe/Luxembourg": "CET-1CEST,M3.5.0,M10.5.0/3", 'Europe/Luxembourg': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Madrid": "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/Malta': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Mariehamn": "EET-2EEST,M3.5.0/3,M10.5.0/4", 'Europe/Mariehamn': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
"Europe/Minsk": "UNK-3", 'Europe/Minsk': 'UNK-3',
"Europe/Monaco": "CET-1CEST,M3.5.0,M10.5.0/3", 'Europe/Monaco': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Moscow": "MSK-3", 'Europe/Moscow': 'MSK-3',
"Europe/Oslo": "CET-1CEST,M3.5.0,M10.5.0/3", 'Europe/Oslo': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Paris": "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/Podgorica': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Prague": "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/Riga': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
"Europe/Rome": "CET-1CEST,M3.5.0,M10.5.0/3", 'Europe/Rome': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Samara": "UNK-4", 'Europe/Samara': 'UNK-4',
"Europe/San_Marino": "CET-1CEST,M3.5.0,M10.5.0/3", 'Europe/San_Marino': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Sarajevo": "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/Saratov': 'UNK-4',
"Europe/Simferopol": "MSK-3", 'Europe/Simferopol': 'MSK-3',
"Europe/Skopje": "CET-1CEST,M3.5.0,M10.5.0/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/Sofia': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
"Europe/Stockholm": "CET-1CEST,M3.5.0,M10.5.0/3", 'Europe/Stockholm': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Tallinn": "EET-2EEST,M3.5.0/3,M10.5.0/4", 'Europe/Tallinn': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
"Europe/Tirane": "CET-1CEST,M3.5.0,M10.5.0/3", 'Europe/Tirane': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Ulyanovsk": "UNK-4", 'Europe/Ulyanovsk': 'UNK-4',
"Europe/Uzhgorod": "EET-2EEST,M3.5.0/3,M10.5.0/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/Vaduz': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Vatican": "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/Vienna': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Vilnius": "EET-2EEST,M3.5.0/3,M10.5.0/4", 'Europe/Vilnius': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
"Europe/Volgograd": "UNK-4", 'Europe/Volgograd': 'UNK-4',
"Europe/Warsaw": "CET-1CEST,M3.5.0,M10.5.0/3", 'Europe/Warsaw': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Europe/Zagreb": "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/Zaporozhye': 'EET-2EEST,M3.5.0/3,M10.5.0/4',
"Europe/Zurich": "CET-1CEST,M3.5.0,M10.5.0/3", 'Europe/Zurich': 'CET-1CEST,M3.5.0,M10.5.0/3',
"Indian/Antananarivo": "EAT-3", 'Indian/Antananarivo': 'EAT-3',
"Indian/Chagos": "UNK-6", 'Indian/Chagos': 'UNK-6',
"Indian/Christmas": "UNK-7", 'Indian/Christmas': 'UNK-7',
"Indian/Cocos": "UNK-6:30", 'Indian/Cocos': 'UNK-6:30',
"Indian/Comoro": "EAT-3", 'Indian/Comoro': 'EAT-3',
"Indian/Kerguelen": "UNK-5", 'Indian/Kerguelen': 'UNK-5',
"Indian/Mahe": "UNK-4", 'Indian/Mahe': 'UNK-4',
"Indian/Maldives": "UNK-5", 'Indian/Maldives': 'UNK-5',
"Indian/Mauritius": "UNK-4", 'Indian/Mauritius': 'UNK-4',
"Indian/Mayotte": "EAT-3", 'Indian/Mayotte': 'EAT-3',
"Indian/Reunion": "UNK-4", 'Indian/Reunion': 'UNK-4',
"Pacific/Apia": "UNK-13UNK,M9.5.0/3,M4.1.0/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/Auckland': 'NZST-12NZDT,M9.5.0,M4.1.0/3',
"Pacific/Bougainville": "UNK-11", 'Pacific/Bougainville': 'UNK-11',
"Pacific/Chatham": "UNK-12:45UNK,M9.5.0/2:45,M4.1.0/3:45", 'Pacific/Chatham': 'UNK-12:45UNK,M9.5.0/2:45,M4.1.0/3:45',
"Pacific/Chuuk": "UNK-10", 'Pacific/Chuuk': 'UNK-10',
"Pacific/Easter": "UNK6UNK,M9.1.6/22,M4.1.6/22", 'Pacific/Easter': 'UNK6UNK,M9.1.6/22,M4.1.6/22',
"Pacific/Efate": "UNK-11", 'Pacific/Efate': 'UNK-11',
"Pacific/Enderbury": "UNK-13", 'Pacific/Enderbury': 'UNK-13',
"Pacific/Fakaofo": "UNK-13", 'Pacific/Fakaofo': 'UNK-13',
"Pacific/Fiji": "UNK-12UNK,M11.2.0,M1.2.3/99", 'Pacific/Fiji': 'UNK-12UNK,M11.2.0,M1.2.3/99',
"Pacific/Funafuti": "UNK-12", 'Pacific/Funafuti': 'UNK-12',
"Pacific/Galapagos": "UNK6", 'Pacific/Galapagos': 'UNK6',
"Pacific/Gambier": "UNK9", 'Pacific/Gambier': 'UNK9',
"Pacific/Guadalcanal": "UNK-11", 'Pacific/Guadalcanal': 'UNK-11',
"Pacific/Guam": "ChST-10", 'Pacific/Guam': 'ChST-10',
"Pacific/Honolulu": "HST10", 'Pacific/Honolulu': 'HST10',
"Pacific/Kiritimati": "UNK-14", 'Pacific/Kiritimati': 'UNK-14',
"Pacific/Kosrae": "UNK-11", 'Pacific/Kosrae': 'UNK-11',
"Pacific/Kwajalein": "UNK-12", 'Pacific/Kwajalein': 'UNK-12',
"Pacific/Majuro": "UNK-12", 'Pacific/Majuro': 'UNK-12',
"Pacific/Marquesas": "UNK9:30", 'Pacific/Marquesas': 'UNK9:30',
"Pacific/Midway": "SST11", 'Pacific/Midway': 'SST11',
"Pacific/Nauru": "UNK-12", 'Pacific/Nauru': 'UNK-12',
"Pacific/Niue": "UNK11", 'Pacific/Niue': 'UNK11',
"Pacific/Norfolk": "UNK-11UNK,M10.1.0,M4.1.0/3", 'Pacific/Norfolk': 'UNK-11UNK,M10.1.0,M4.1.0/3',
"Pacific/Noumea": "UNK-11", 'Pacific/Noumea': 'UNK-11',
"Pacific/Pago_Pago": "SST11", 'Pacific/Pago_Pago': 'SST11',
"Pacific/Palau": "UNK-9", 'Pacific/Palau': 'UNK-9',
"Pacific/Pitcairn": "UNK8", 'Pacific/Pitcairn': 'UNK8',
"Pacific/Pohnpei": "UNK-11", 'Pacific/Pohnpei': 'UNK-11',
"Pacific/Port_Moresby": "UNK-10", 'Pacific/Port_Moresby': 'UNK-10',
"Pacific/Rarotonga": "UNK10", 'Pacific/Rarotonga': 'UNK10',
"Pacific/Saipan": "ChST-10", 'Pacific/Saipan': 'ChST-10',
"Pacific/Tahiti": "UNK10", 'Pacific/Tahiti': 'UNK10',
"Pacific/Tarawa": "UNK-12", 'Pacific/Tarawa': 'UNK-12',
"Pacific/Tongatapu": "UNK-13", 'Pacific/Tongatapu': 'UNK-13',
"Pacific/Wake": "UNK-12", 'Pacific/Wake': 'UNK-12',
"Pacific/Wallis": "UNK-12" 'Pacific/Wallis': 'UNK-12'
} };
export function selectedTimeZone(label: string, format: string) { export function selectedTimeZone(label: string, format: string) {
return TIME_ZONES[label] === format ? label : undefined; return TIME_ZONES[label] === format ? label : undefined;
} }
export function timeZoneSelectItems() { export function timeZoneSelectItems() {
return Object.keys(TIME_ZONES).map(label => ( return Object.keys(TIME_ZONES).map((label) => (
<MenuItem key={label} value={label}>{label}</MenuItem> <MenuItem key={label} value={label}>
{label}
</MenuItem>
)); ));
} }

View File

@@ -1,4 +1,4 @@
import parseMilliseconds from 'parse-ms' import parseMilliseconds from 'parse-ms';
const LOCALE_FORMAT = new Intl.DateTimeFormat([...window.navigator.languages], { const LOCALE_FORMAT = new Intl.DateTimeFormat([...window.navigator.languages], {
day: 'numeric', day: 'numeric',
@@ -7,37 +7,37 @@ const LOCALE_FORMAT = new Intl.DateTimeFormat([...window.navigator.languages], {
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
second: 'numeric', second: 'numeric',
hour12: false, hour12: false
}) });
export const formatDateTime = (dateTime: string) => { export const formatDateTime = (dateTime: string) => {
return LOCALE_FORMAT.format(new Date(dateTime.substr(0, 19))) return LOCALE_FORMAT.format(new Date(dateTime.substr(0, 19)));
} };
export const formatLocalDateTime = (date: Date) => { export const formatLocalDateTime = (date: Date) => {
return new Date(date.getTime() - date.getTimezoneOffset() * 60000) return new Date(date.getTime() - date.getTimezoneOffset() * 60000)
.toISOString() .toISOString()
.slice(0, -1) .slice(0, -1)
.substr(0, 19) .substr(0, 19);
} };
export const formatDuration = (duration: number) => { export const formatDuration = (duration: number) => {
const { days, hours, minutes, seconds } = parseMilliseconds(duration * 1000) const { days, hours, minutes, seconds } = parseMilliseconds(duration * 1000);
var formatted = '' let formatted = '';
if (days) { if (days) {
formatted += pluralize(days, 'day') formatted += pluralize(days, 'day');
} }
if (formatted || hours) { if (formatted || hours) {
formatted += pluralize(hours, 'hour') formatted += pluralize(hours, 'hour');
} }
if (formatted || minutes) { if (formatted || minutes) {
formatted += pluralize(minutes, 'minute') formatted += pluralize(minutes, 'minute');
} }
if (formatted || seconds) { if (formatted || seconds) {
formatted += pluralize(seconds, 'second') formatted += pluralize(seconds, 'second');
} }
return formatted return formatted;
} };
const pluralize = (count: number, noun: string, suffix: string = 's') => const pluralize = (count: number, noun: string, suffix = 's') =>
` ${count} ${noun}${count !== 1 ? suffix : ''} ` ` ${count} ${noun}${count !== 1 ? suffix : ''} `;

View File

@@ -1,23 +1,23 @@
export enum NTPSyncStatus { export enum NTPSyncStatus {
NTP_INACTIVE = 0, NTP_INACTIVE = 0,
NTP_ACTIVE = 1, NTP_ACTIVE = 1
} }
export interface NTPStatus { export interface NTPStatus {
status: NTPSyncStatus status: NTPSyncStatus;
utc_time: string utc_time: string;
local_time: string local_time: string;
server: string server: string;
uptime: number uptime: number;
} }
export interface NTPSettings { export interface NTPSettings {
enabled: boolean enabled: boolean;
server: string server: string;
tz_label: string tz_label: string;
tz_format: string tz_format: string;
} }
export interface Time { export interface Time {
local_time: string local_time: string;
} }

View File

@@ -1,23 +1,24 @@
import React from 'react';
import MenuItem from '@material-ui/core/MenuItem'; import MenuItem from '@material-ui/core/MenuItem';
type BoardProfiles = { type BoardProfiles = {
[name: string]: string [name: string]: string;
}; };
export const BOARD_PROFILES: BoardProfiles = { export const BOARD_PROFILES: BoardProfiles = {
"S32": "BBQKees Gateway S32", S32: 'BBQKees Gateway S32',
"E32": "BBQKees Gateway E32", E32: 'BBQKees Gateway E32',
"NODEMCU": "NodeMCU 32S", NODEMCU: 'NodeMCU 32S',
"MH-ET": "MH-ET Live D1 Mini", 'MH-ET': 'MH-ET Live D1 Mini',
"LOLIN": "Lolin D32", LOLIN: 'Lolin D32',
"OLIMEX": "Olimex ESP32-EVB", OLIMEX: 'Olimex ESP32-EVB',
"TLK110": "Generic Ethernet (TLK110)", TLK110: 'Generic Ethernet (TLK110)',
"LAN8720": "Generic Ethernet (LAN8720)" LAN8720: 'Generic Ethernet (LAN8720)'
} };
export function boardProfileSelectItems() { export function boardProfileSelectItems() {
return Object.keys(BOARD_PROFILES).map(code => ( return Object.keys(BOARD_PROFILES).map((code) => (
<MenuItem key={code} value={code}>{BOARD_PROFILES[code]}</MenuItem> <MenuItem key={code} value={code}>
)); {BOARD_PROFILES[code]}
</MenuItem>
));
} }

View File

@@ -1,5 +1,5 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
import { Tabs, Tab } from '@material-ui/core'; import { Tabs, Tab } from '@material-ui/core';
@@ -12,30 +12,43 @@ import EMSESPDevicesController from './EMSESPDevicesController';
import EMSESPHelp from './EMSESPHelp'; import EMSESPHelp from './EMSESPHelp';
class EMSESP extends Component<RouteComponentProps> { class EMSESP extends Component<RouteComponentProps> {
handleTabChange = (path: string) => {
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
this.props.history.push(path); this.props.history.push(path);
}; };
render() { render() {
return ( return (
<MenuAppBar sectionTitle="Dashboard"> <MenuAppBar sectionTitle="Dashboard">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> <Tabs
value={this.props.match.url}
onChange={(e, path) => this.handleTabChange(path)}
variant="fullWidth"
>
<Tab value={`/${PROJECT_PATH}/devices`} label="Devices & Sensors" /> <Tab value={`/${PROJECT_PATH}/devices`} label="Devices & Sensors" />
<Tab value={`/${PROJECT_PATH}/status`} label="EMS Status" /> <Tab value={`/${PROJECT_PATH}/status`} label="EMS Status" />
<Tab value={`/${PROJECT_PATH}/help`} label="EMS-ESP Help" /> <Tab value={`/${PROJECT_PATH}/help`} label="EMS-ESP Help" />
</Tabs> </Tabs>
<Switch> <Switch>
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/devices`} component={EMSESPDevicesController} /> <AuthenticatedRoute
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/status`} component={EMSESPStatusController} /> exact
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/help`} component={EMSESPHelp} /> path={`/${PROJECT_PATH}/devices`}
component={EMSESPDevicesController}
/>
<AuthenticatedRoute
exact
path={`/${PROJECT_PATH}/status`}
component={EMSESPStatusController}
/>
<AuthenticatedRoute
exact
path={`/${PROJECT_PATH}/help`}
component={EMSESPHelp}
/>
<Redirect to={`/${PROJECT_PATH}/devices`} /> <Redirect to={`/${PROJECT_PATH}/devices`} />
</Switch> </Switch>
</MenuAppBar> </MenuAppBar>
);
)
} }
} }
export default EMSESP; export default EMSESP;

View File

@@ -1,30 +1,34 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { ENDPOINT_ROOT } from '../api'; import { ENDPOINT_ROOT } from '../api';
import EMSESPDevicesForm from './EMSESPDevicesForm'; import EMSESPDevicesForm from './EMSESPDevicesForm';
import { EMSESPDevices } from './EMSESPtypes'; import { EMSESPDevices } from './EMSESPtypes';
export const EMSESP_DEVICES_ENDPOINT = ENDPOINT_ROOT + "allDevices"; export const EMSESP_DEVICES_ENDPOINT = ENDPOINT_ROOT + 'allDevices';
type EMSESPDevicesControllerProps = RestControllerProps<EMSESPDevices>; type EMSESPDevicesControllerProps = RestControllerProps<EMSESPDevices>;
class EMSESPDevicesController extends Component<EMSESPDevicesControllerProps> { class EMSESPDevicesController extends Component<EMSESPDevicesControllerProps> {
componentDidMount() {
this.props.loadData();
}
componentDidMount() { render() {
this.props.loadData(); return (
} <SectionContent title="Devices & Sensors">
<RestFormLoader
render() { {...this.props}
return ( render={(formProps) => <EMSESPDevicesForm {...formProps} />}
<SectionContent title="Devices & Sensors"> />
<RestFormLoader </SectionContent>
{...this.props} );
render={formProps => <EMSESPDevicesForm {...formProps} />} }
/>
</SectionContent>
)
}
} }
export default restController(EMSESP_DEVICES_ENDPOINT, EMSESPDevicesController); export default restController(EMSESP_DEVICES_ENDPOINT, EMSESPDevicesController);

View File

@@ -1,38 +1,62 @@
import React, { Component, Fragment } from "react"; import React, { Component, Fragment } from 'react';
import { withStyles, Theme, createStyles } from "@material-ui/core/styles"; import { withStyles, Theme, createStyles } from '@material-ui/core/styles';
import { import {
Table, TableBody, TableCell, TableHead, TableRow, TableContainer, withWidth, WithWidthProps, isWidthDown, Table,
Button, Tooltip, DialogTitle, DialogContent, DialogActions, Box, Dialog, Typography TableBody,
} from "@material-ui/core"; TableCell,
TableHead,
TableRow,
TableContainer,
withWidth,
WithWidthProps,
isWidthDown,
Button,
Tooltip,
DialogTitle,
DialogContent,
DialogActions,
Box,
Dialog,
Typography
} from '@material-ui/core';
import RefreshIcon from "@material-ui/icons/Refresh"; import RefreshIcon from '@material-ui/icons/Refresh';
import ListIcon from "@material-ui/icons/List"; import ListIcon from '@material-ui/icons/List';
import IconButton from '@material-ui/core/IconButton'; import IconButton from '@material-ui/core/IconButton';
import EditIcon from '@material-ui/icons/Edit'; import EditIcon from '@material-ui/icons/Edit';
import { redirectingAuthorizedFetch, withAuthenticatedContext, AuthenticatedContextProps } from "../authentication"; import {
import { RestFormProps, FormButton, extractEventValue } from "../components"; redirectingAuthorizedFetch,
withAuthenticatedContext,
AuthenticatedContextProps
} from '../authentication';
import { RestFormProps, FormButton, extractEventValue } from '../components';
import { EMSESPDevices, EMSESPDeviceData, Device, DeviceValue } from "./EMSESPtypes"; import {
EMSESPDevices,
EMSESPDeviceData,
Device,
DeviceValue
} from './EMSESPtypes';
import ValueForm from './ValueForm'; import ValueForm from './ValueForm';
import { ENDPOINT_ROOT } from "../api"; import { ENDPOINT_ROOT } from '../api';
export const SCANDEVICES_ENDPOINT = ENDPOINT_ROOT + "scanDevices"; export const SCANDEVICES_ENDPOINT = ENDPOINT_ROOT + 'scanDevices';
export const DEVICE_DATA_ENDPOINT = ENDPOINT_ROOT + "deviceData"; export const DEVICE_DATA_ENDPOINT = ENDPOINT_ROOT + 'deviceData';
export const WRITE_VALUE_ENDPOINT = ENDPOINT_ROOT + "writeValue"; export const WRITE_VALUE_ENDPOINT = ENDPOINT_ROOT + 'writeValue';
const StyledTableCell = withStyles((theme: Theme) => const StyledTableCell = withStyles((theme: Theme) =>
createStyles({ createStyles({
head: { head: {
backgroundColor: theme.palette.common.black, backgroundColor: theme.palette.common.black,
color: theme.palette.common.white, color: theme.palette.common.white
}, },
body: { body: {
fontSize: 14, fontSize: 14
}, }
}) })
)(TableCell); )(TableCell);
@@ -42,8 +66,8 @@ const CustomTooltip = withStyles((theme: Theme) => ({
color: 'white', color: 'white',
boxShadow: theme.shadows[1], boxShadow: theme.shadows[1],
fontSize: 11, fontSize: 11,
border: '1px solid #dadde9', border: '1px solid #dadde9'
}, }
}))(Tooltip); }))(Tooltip);
function compareDevices(a: Device, b: Device) { function compareDevices(a: Device, b: Device) {
@@ -64,63 +88,81 @@ interface EMSESPDevicesFormState {
devicevalue?: DeviceValue; devicevalue?: DeviceValue;
} }
type EMSESPDevicesFormProps = RestFormProps<EMSESPDevices> & AuthenticatedContextProps & WithWidthProps; type EMSESPDevicesFormProps = RestFormProps<EMSESPDevices> &
AuthenticatedContextProps &
WithWidthProps;
function formatTemp(t: string) { function formatTemp(t: string) {
if (t == null) { if (t == null) {
return "n/a"; return 'n/a';
} }
return t + " °C"; return t + ' °C';
} }
function formatUnit(u: string) { function formatUnit(u: string) {
if (u == null) { if (u == null) {
return u; return u;
} }
return " " + u; return ' ' + u;
} }
class EMSESPDevicesForm extends Component<EMSESPDevicesFormProps, EMSESPDevicesFormState> { class EMSESPDevicesForm extends Component<
EMSESPDevicesFormProps,
EMSESPDevicesFormState
> {
state: EMSESPDevicesFormState = { state: EMSESPDevicesFormState = {
confirmScanDevices: false, confirmScanDevices: false,
processing: false processing: false
}; };
handleValueChange = (name: keyof DeviceValue) => (event: React.ChangeEvent<HTMLInputElement>) => { handleValueChange = (name: keyof DeviceValue) => (
this.setState({ devicevalue: { ...this.state.devicevalue!, [name]: extractEventValue(event) } }); event: React.ChangeEvent<HTMLInputElement>
) => {
this.setState({
devicevalue: {
...this.state.devicevalue!,
[name]: extractEventValue(event)
}
});
}; };
cancelEditingValue = () => { cancelEditingValue = () => {
this.setState({ this.setState({
devicevalue: undefined devicevalue: undefined
}); });
} };
doneEditingValue = () => { doneEditingValue = () => {
const { devicevalue } = this.state; const { devicevalue } = this.state;
redirectingAuthorizedFetch(WRITE_VALUE_ENDPOINT, { redirectingAuthorizedFetch(WRITE_VALUE_ENDPOINT, {
method: "POST", method: 'POST',
body: JSON.stringify({ devicevalue: devicevalue }), body: JSON.stringify({ devicevalue: devicevalue }),
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json'
}, }
}) })
.then((response) => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
this.props.enqueueSnackbar("Write command sent to device", { variant: "success" }); this.props.enqueueSnackbar('Write command sent to device', {
variant: 'success'
});
} else if (response.status === 204) { } else if (response.status === 204) {
this.props.enqueueSnackbar("Write command failed", { variant: "error" }); this.props.enqueueSnackbar('Write command failed', {
variant: 'error'
});
} else if (response.status === 403) { } else if (response.status === 403) {
this.props.enqueueSnackbar("Write access denied", { variant: "error" }); this.props.enqueueSnackbar('Write access denied', {
variant: 'error'
});
} else { } else {
throw Error("Unexpected response code: " + response.status); throw Error('Unexpected response code: ' + response.status);
} }
}) })
.catch((error) => { .catch((error) => {
this.props.enqueueSnackbar( this.props.enqueueSnackbar(error.message || 'Problem writing value', {
error.message || "Problem writing value", { variant: "error" } variant: 'error'
); });
}); });
if (devicevalue) { if (devicevalue) {
@@ -128,20 +170,19 @@ class EMSESPDevicesForm extends Component<EMSESPDevicesFormProps, EMSESPDevicesF
devicevalue: undefined devicevalue: undefined
}); });
} }
}; };
sendCommand = (i: any) => { sendCommand = (i: number) => {
this.setState({ this.setState({
devicevalue: { devicevalue: {
id: this.state.selectedDevice!, id: this.state.selectedDevice!,
data: this.state.deviceData?.data[i]!, data: this.state.deviceData?.data[i]!,
uom: this.state.deviceData?.data[i + 1]!, uom: this.state.deviceData?.data[i + 1]!,
name: this.state.deviceData?.data[i + 2]!, name: this.state.deviceData?.data[i + 2]!,
cmd: this.state.deviceData?.data[i + 3]!, cmd: this.state.deviceData?.data[i + 3]!
} }
}); });
} };
noDevices = () => { noDevices = () => {
return this.props.data.devices.length === 0; return this.props.data.devices.length === 0;
@@ -166,22 +207,41 @@ class EMSESPDevicesForm extends Component<EMSESPDevicesFormProps, EMSESPDevicesF
{!this.noDevices() && ( {!this.noDevices() && (
<Table <Table
size="small" size="small"
padding={isWidthDown("xs", width!) ? "none" : "default"} padding={isWidthDown('xs', width!) ? 'none' : 'default'}
> >
<TableBody> <TableBody>
{data.devices.sort(compareDevices).map((device) => ( {data.devices.sort(compareDevices).map((device) => (
<TableRow hover key={device.id} onClick={() => this.handleRowClick(device)}> <TableRow
hover
key={device.id}
onClick={() => this.handleRowClick(device)}
>
<TableCell> <TableCell>
<CustomTooltip <CustomTooltip
title={"DeviceID:0x" + ("00" + device.deviceid.toString(16).toUpperCase()).slice(-2) + " ProductID:" + device.productid + " Version:" + device.version} title={
'DeviceID:0x' +
(
'00' + device.deviceid.toString(16).toUpperCase()
).slice(-2) +
' ProductID:' +
device.productid +
' Version:' +
device.version
}
placement="right-end" placement="right-end"
> >
<Button startIcon={<ListIcon />} size="small" variant="outlined"> <Button
startIcon={<ListIcon />}
size="small"
variant="outlined"
>
{device.type} {device.type}
</Button> </Button>
</CustomTooltip> </CustomTooltip>
</TableCell> </TableCell>
<TableCell align="right">{device.brand + " " + device.name} </TableCell> <TableCell align="right">
{device.brand + ' ' + device.name}{' '}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
@@ -191,10 +251,13 @@ class EMSESPDevicesForm extends Component<EMSESPDevicesFormProps, EMSESPDevicesF
<Box <Box
bgcolor="error.main" bgcolor="error.main"
color="error.contrastText" color="error.contrastText"
p={2} mt={2} mb={2} p={2}
mt={2}
mb={2}
> >
<Typography variant="body1"> <Typography variant="body1">
No EMS devices found. Check the connections and for possible Tx errors. No EMS devices found. Check the connections and for possible Tx
errors.
</Typography> </Typography>
</Box> </Box>
)} )}
@@ -255,14 +318,25 @@ class EMSESPDevicesForm extends Component<EMSESPDevicesFormProps, EMSESPDevicesF
> >
<DialogTitle>Confirm Scan Devices</DialogTitle> <DialogTitle>Confirm Scan Devices</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
Are you sure you want to initiate a scan on the EMS bus for all new devices? Are you sure you want to initiate a scan on the EMS bus for all new
devices?
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button variant="contained" onClick={this.onScanDevicesRejected} color="secondary"> <Button
variant="contained"
onClick={this.onScanDevicesRejected}
color="secondary"
>
Cancel Cancel
</Button> </Button>
<Button <Button
startIcon={<RefreshIcon />} variant="contained" onClick={this.onScanDevicesConfirmed} disabled={this.state.processing} color="primary" autoFocus> startIcon={<RefreshIcon />}
variant="contained"
onClick={this.onScanDevicesConfirmed}
disabled={this.state.processing}
color="primary"
autoFocus
>
Start Scan Start Scan
</Button> </Button>
</DialogActions> </DialogActions>
@@ -283,17 +357,17 @@ class EMSESPDevicesForm extends Component<EMSESPDevicesFormProps, EMSESPDevicesF
redirectingAuthorizedFetch(SCANDEVICES_ENDPOINT) redirectingAuthorizedFetch(SCANDEVICES_ENDPOINT)
.then((response) => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
this.props.enqueueSnackbar("Device scan is starting...", { this.props.enqueueSnackbar('Device scan is starting...', {
variant: "info", variant: 'info'
}); });
this.setState({ processing: false, confirmScanDevices: false }); this.setState({ processing: false, confirmScanDevices: false });
} else { } else {
throw Error("Invalid status code: " + response.status); throw Error('Invalid status code: ' + response.status);
} }
}) })
.catch((error) => { .catch((error) => {
this.props.enqueueSnackbar(error.message || "Problem with scan", { this.props.enqueueSnackbar(error.message || 'Problem with scan', {
variant: "error", variant: 'error'
}); });
this.setState({ processing: false, confirmScanDevices: false }); this.setState({ processing: false, confirmScanDevices: false });
}); });
@@ -302,25 +376,25 @@ class EMSESPDevicesForm extends Component<EMSESPDevicesFormProps, EMSESPDevicesF
handleRowClick = (device: any) => { handleRowClick = (device: any) => {
this.setState({ selectedDevice: device.id, deviceData: undefined }); this.setState({ selectedDevice: device.id, deviceData: undefined });
redirectingAuthorizedFetch(DEVICE_DATA_ENDPOINT, { redirectingAuthorizedFetch(DEVICE_DATA_ENDPOINT, {
method: "POST", method: 'POST',
body: JSON.stringify({ id: device.id }), body: JSON.stringify({ id: device.id }),
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json'
}, }
}) })
.then((response) => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
return response.json(); return response.json();
} }
throw Error("Unexpected response code: " + response.status); throw Error('Unexpected response code: ' + response.status);
}) })
.then((json) => { .then((json) => {
this.setState({ deviceData: json }); this.setState({ deviceData: json });
}) })
.catch((error) => { .catch((error) => {
this.props.enqueueSnackbar( this.props.enqueueSnackbar(
error.message || "Problem getting device data", error.message || 'Problem getting device data',
{ variant: "error" } { variant: 'error' }
); );
this.setState({ deviceData: undefined }); this.setState({ deviceData: undefined });
}); });
@@ -351,10 +425,9 @@ class EMSESPDevicesForm extends Component<EMSESPDevicesFormProps, EMSESPDevicesF
<TableContainer> <TableContainer>
<Table <Table
size="small" size="small"
padding={isWidthDown("xs", width!) ? "none" : "default"} padding={isWidthDown('xs', width!) ? 'none' : 'default'}
> >
<TableHead> <TableHead></TableHead>
</TableHead>
<TableBody> <TableBody>
{deviceData.data.map((item, i) => { {deviceData.data.map((item, i) => {
if (i % 4) { if (i % 4) {
@@ -362,19 +435,30 @@ class EMSESPDevicesForm extends Component<EMSESPDevicesFormProps, EMSESPDevicesF
} else { } else {
return ( return (
<TableRow hover key={i}> <TableRow hover key={i}>
<TableCell padding="checkbox" style={{ width: 18 }} > <TableCell padding="checkbox" style={{ width: 18 }}>
{deviceData.data[i + 3] && me.admin && ( {deviceData.data[i + 3] && me.admin && (
<CustomTooltip title="change value" placement="left-end" <CustomTooltip
title="change value"
placement="left-end"
> >
<IconButton edge="start" size="small" aria-label="Edit" <IconButton
onClick={() => this.sendCommand(i)}> edge="start"
size="small"
aria-label="Edit"
onClick={() => this.sendCommand(i)}
>
<EditIcon color="primary" fontSize="small" /> <EditIcon color="primary" fontSize="small" />
</IconButton> </IconButton>
</CustomTooltip> </CustomTooltip>
)} )}
</TableCell> </TableCell>
<TableCell padding="none" component="th" scope="row">{deviceData.data[i + 2]}</TableCell> <TableCell padding="none" component="th" scope="row">
<TableCell padding="none" align="right">{deviceData.data[i]}{formatUnit(deviceData.data[i + 1])}</TableCell> {deviceData.data[i + 2]}
</TableCell>
<TableCell padding="none" align="right">
{deviceData.data[i]}
{formatUnit(deviceData.data[i + 1])}
</TableCell>
</TableRow> </TableRow>
); );
} }
@@ -390,7 +474,7 @@ class EMSESPDevicesForm extends Component<EMSESPDevicesFormProps, EMSESPDevicesF
</Typography> </Typography>
</Box> </Box>
)} )}
</Fragment > </Fragment>
); );
} }
@@ -405,26 +489,34 @@ class EMSESPDevicesForm extends Component<EMSESPDevicesFormProps, EMSESPDevicesF
<br></br> <br></br>
<Box display="flex" flexWrap="wrap"> <Box display="flex" flexWrap="wrap">
<Box flexGrow={1} padding={1}> <Box flexGrow={1} padding={1}>
<FormButton startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData} > <FormButton
startIcon={<RefreshIcon />}
variant="contained"
color="secondary"
onClick={this.props.loadData}
>
Refresh Refresh
</FormButton> </FormButton>
</Box> </Box>
<Box flexWrap="none" padding={1} whiteSpace="nowrap"> <Box flexWrap="none" padding={1} whiteSpace="nowrap">
<FormButton startIcon={<RefreshIcon />} variant="contained" onClick={this.onScanDevices} > <FormButton
startIcon={<RefreshIcon />}
variant="contained"
onClick={this.onScanDevices}
>
Scan Devices Scan Devices
</FormButton> </FormButton>
</Box> </Box>
</Box> </Box>
{this.renderScanDevicesDialog()} {this.renderScanDevicesDialog()}
{ {devicevalue && (
devicevalue &&
<ValueForm <ValueForm
devicevalue={devicevalue} devicevalue={devicevalue}
onDoneEditing={this.doneEditingValue} onDoneEditing={this.doneEditingValue}
onCancelEditing={this.cancelEditingValue} onCancelEditing={this.cancelEditingValue}
handleValueChange={this.handleValueChange} handleValueChange={this.handleValueChange}
/> />
} )}
</Fragment> </Fragment>
); );
} }

View File

@@ -1,85 +1,110 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { Typography, Box, List, ListItem, ListItemText, Link, ListItemAvatar } from '@material-ui/core'; import {
Typography,
Box,
List,
ListItem,
ListItemText,
Link,
ListItemAvatar
} from '@material-ui/core';
import { SectionContent } from '../components'; import { SectionContent } from '../components';
import CommentIcon from "@material-ui/icons/CommentTwoTone"; import CommentIcon from '@material-ui/icons/CommentTwoTone';
import MenuBookIcon from "@material-ui/icons/MenuBookTwoTone"; import MenuBookIcon from '@material-ui/icons/MenuBookTwoTone';
import GitHubIcon from "@material-ui/icons/GitHub"; import GitHubIcon from '@material-ui/icons/GitHub';
import StarIcon from "@material-ui/icons/Star"; import StarIcon from '@material-ui/icons/Star';
import ImportExportIcon from "@material-ui/icons/ImportExport"; import ImportExportIcon from '@material-ui/icons/ImportExport';
import BugReportIcon from "@material-ui/icons/BugReportTwoTone"; import BugReportIcon from '@material-ui/icons/BugReportTwoTone';
export const WebAPISystemSettings = window.location.origin + "/api/system/settings"; export const WebAPISystemSettings =
export const WebAPISystemInfo = window.location.origin + "/api/system/info"; window.location.origin + '/api/system/settings';
export const WebAPISystemInfo = window.location.origin + '/api/system/info';
class EMSESPHelp extends Component { class EMSESPHelp extends Component {
render() {
return (
<SectionContent title="EMS-ESP Help" titleGutter>
<List>
<ListItem>
<ListItemAvatar>
<MenuBookIcon />
</ListItemAvatar>
<ListItemText>
For the latest news and updates go to the{' '}
<Link href="https://emsesp.github.io/docs" color="primary">
{'official documentation'}&nbsp;website
</Link>
</ListItemText>
</ListItem>
render() { <ListItem>
return ( <ListItemAvatar>
<SectionContent title='EMS-ESP Help' titleGutter> <CommentIcon />
</ListItemAvatar>
<ListItemText>
For live community chat join our{' '}
<Link href="https://discord.gg/3J3GgnzpyT" color="primary">
{'Discord'}&nbsp;server
</Link>
</ListItemText>
</ListItem>
<List> <ListItem>
<ListItemAvatar>
<GitHubIcon />
</ListItemAvatar>
<ListItemText>
To report an issue or feature request go to{' '}
<Link
href="https://github.com/emsesp/EMS-ESP32/issues/new/choose"
color="primary"
>
{'click here'}
</Link>
</ListItemText>
</ListItem>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<MenuBookIcon /> <ImportExportIcon />
</ListItemAvatar> </ListItemAvatar>
<ListItemText> <ListItemText>
For the latest news and updates go to the <Link href="https://emsesp.github.io/docs" color="primary">{'official documentation'}&nbsp;website</Link> To export your system settings{' '}
</ListItemText> <Link target="_blank" href={WebAPISystemSettings} color="primary">
</ListItem> {'click here'}
</Link>
</ListItemText>
</ListItem>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<CommentIcon /> <BugReportIcon />
</ListItemAvatar> </ListItemAvatar>
<ListItemText> <ListItemText>
For live community chat join our <Link href="https://discord.gg/3J3GgnzpyT" color="primary">{'Discord'}&nbsp;server</Link> To export the current status of EMS-ESP{' '}
</ListItemText> <Link target="_blank" href={WebAPISystemInfo} color="primary">
</ListItem> {'click here'}
</Link>
<ListItem> </ListItemText>
<ListItemAvatar> </ListItem>
<GitHubIcon /> </List>
</ListItemAvatar>
<ListItemText>
To report an issue or feature request go to <Link href="https://github.com/emsesp/EMS-ESP32/issues/new/choose" color="primary">{'click here'}</Link>
</ListItemText>
</ListItem>
<ListItem>
<ListItemAvatar>
<ImportExportIcon />
</ListItemAvatar>
<ListItemText>
To export your system settings <Link target="_blank" href={WebAPISystemSettings} color="primary">{'click here'}</Link>
</ListItemText>
</ListItem>
<ListItem>
<ListItemAvatar>
<BugReportIcon />
</ListItemAvatar>
<ListItemText>
To export the current status of EMS-ESP <Link target="_blank" href={WebAPISystemInfo} color="primary">{'click here'}</Link>
</ListItemText>
</ListItem>
</List>
<Box bgcolor="info.main" border={1} p={3} mt={1} mb={0}>
<Typography variant="h6">
EMS-ESP is free and open-source.
<br></br>Please consider supporting this project by giving it a <StarIcon style={{ color: '#fdff3a' }} /> on our <Link href="https://github.com/emsesp/EMS-ESP32" color="primary">{'GitHub page'}</Link>.
</Typography>
</Box>
<br></br>
</SectionContent>
)
}
<Box bgcolor="info.main" border={1} p={3} mt={1} mb={0}>
<Typography variant="h6">
EMS-ESP is free and open-source.
<br></br>Please consider supporting this project by giving it a{' '}
<StarIcon style={{ color: '#fdff3a' }} /> on our{' '}
<Link href="https://github.com/emsesp/EMS-ESP32" color="primary">
{'GitHub page'}
</Link>
.
</Typography>
</Box>
<br></br>
</SectionContent>
);
}
} }
export default EMSESPHelp; export default EMSESPHelp;

View File

@@ -1,5 +1,5 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
import { Tabs, Tab } from '@material-ui/core'; import { Tabs, Tab } from '@material-ui/core';
@@ -10,26 +10,31 @@ import { AuthenticatedRoute } from '../authentication';
import EMSESPSettingsController from './EMSESPSettingsController'; import EMSESPSettingsController from './EMSESPSettingsController';
class EMSESP extends Component<RouteComponentProps> { class EMSESP extends Component<RouteComponentProps> {
handleTabChange = (path: string) => {
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
this.props.history.push(path); this.props.history.push(path);
}; };
render() { render() {
return ( return (
<MenuAppBar sectionTitle="Settings"> <MenuAppBar sectionTitle="Settings">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> <Tabs
value={this.props.match.url}
onChange={(e, path) => this.handleTabChange(path)}
variant="fullWidth"
>
<Tab value={`/${PROJECT_PATH}/settings`} label="EMS-ESP Settings" /> <Tab value={`/${PROJECT_PATH}/settings`} label="EMS-ESP Settings" />
</Tabs> </Tabs>
<Switch> <Switch>
<AuthenticatedRoute exact path={`/${PROJECT_PATH}/settings`} component={EMSESPSettingsController} /> <AuthenticatedRoute
exact
path={`/${PROJECT_PATH}/settings`}
component={EMSESPSettingsController}
/>
<Redirect to={`/${PROJECT_PATH}/settings`} /> <Redirect to={`/${PROJECT_PATH}/settings`} />
</Switch> </Switch>
</MenuAppBar> </MenuAppBar>
);
)
} }
} }
export default EMSESP; export default EMSESP;

View File

@@ -1,38 +1,41 @@
import React, { Component } from 'react'; import { Component } from 'react';
// import { Container } from '@material-ui/core';
import { ENDPOINT_ROOT } from '../api'; import { ENDPOINT_ROOT } from '../api';
import EMSESPSettingsForm from './EMSESPSettingsForm'; import EMSESPSettingsForm from './EMSESPSettingsForm';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { EMSESPSettings } from './EMSESPtypes'; import { EMSESPSettings } from './EMSESPtypes';
export const EMSESP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + "emsespSettings"; export const EMSESP_SETTINGS_ENDPOINT = ENDPOINT_ROOT + 'emsespSettings';
type EMSESPSettingsControllerProps = RestControllerProps<EMSESPSettings>; type EMSESPSettingsControllerProps = RestControllerProps<EMSESPSettings>;
class EMSESPSettingsController extends Component<EMSESPSettingsControllerProps> { class EMSESPSettingsController extends Component<EMSESPSettingsControllerProps> {
componentDidMount() {
this.props.loadData();
}
componentDidMount() { render() {
this.props.loadData(); return (
} // <Container maxWidth="md" disableGutters>
<SectionContent title="" titleGutter>
render() { <RestFormLoader
return ( {...this.props}
// <Container maxWidth="md" disableGutters> render={(formProps) => <EMSESPSettingsForm {...formProps} />}
<SectionContent title='' titleGutter> />
<RestFormLoader </SectionContent>
{...this.props} // </Container>
render={formProps => ( );
<EMSESPSettingsForm {...formProps} /> }
)}
/>
</SectionContent>
// </Container>
)
}
} }
export default restController(EMSESP_SETTINGS_ENDPOINT, EMSESPSettingsController); export default restController(
EMSESP_SETTINGS_ENDPOINT,
EMSESPSettingsController
);

View File

@@ -1,9 +1,9 @@
import React from "react"; import React from 'react';
import { import {
ValidatorForm, ValidatorForm,
TextValidator, TextValidator,
SelectValidator, SelectValidator
} from "react-material-ui-form-validator"; } from 'react-material-ui-form-validator';
import { import {
Checkbox, Checkbox,
@@ -11,34 +11,34 @@ import {
Box, Box,
Link, Link,
withWidth, withWidth,
WithWidthProps, WithWidthProps
} from "@material-ui/core"; } from '@material-ui/core';
import SaveIcon from "@material-ui/icons/Save"; import SaveIcon from '@material-ui/icons/Save';
import MenuItem from "@material-ui/core/MenuItem"; import MenuItem from '@material-ui/core/MenuItem';
import Grid from "@material-ui/core/Grid"; import Grid from '@material-ui/core/Grid';
import { import {
redirectingAuthorizedFetch, redirectingAuthorizedFetch,
withAuthenticatedContext, withAuthenticatedContext,
AuthenticatedContextProps, AuthenticatedContextProps
} from "../authentication"; } from '../authentication';
import { import {
RestFormProps, RestFormProps,
FormActions, FormActions,
FormButton, FormButton,
BlockFormControlLabel, BlockFormControlLabel
} from "../components"; } from '../components';
import { isIP, optional } from "../validators"; import { isIP, optional } from '../validators';
import { EMSESPSettings } from "./EMSESPtypes"; import { EMSESPSettings } from './EMSESPtypes';
import { boardProfileSelectItems } from "./EMSESPBoardProfiles"; import { boardProfileSelectItems } from './EMSESPBoardProfiles';
import { ENDPOINT_ROOT } from "../api"; import { ENDPOINT_ROOT } from '../api';
export const BOARD_PROFILE_ENDPOINT = ENDPOINT_ROOT + "boardProfile"; export const BOARD_PROFILE_ENDPOINT = ENDPOINT_ROOT + 'boardProfile';
type EMSESPSettingsFormProps = RestFormProps<EMSESPSettings> & type EMSESPSettingsFormProps = RestFormProps<EMSESPSettings> &
AuthenticatedContextProps & AuthenticatedContextProps &
@@ -50,38 +50,38 @@ interface EMSESPSettingsFormState {
class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> { class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
state: EMSESPSettingsFormState = { state: EMSESPSettingsFormState = {
processing: false, processing: false
}; };
componentDidMount() { componentDidMount() {
ValidatorForm.addValidationRule("isOptionalIP", optional(isIP)); ValidatorForm.addValidationRule('isOptionalIP', optional(isIP));
} }
changeBoardProfile = (event: React.ChangeEvent<HTMLSelectElement>) => { changeBoardProfile = (event: React.ChangeEvent<HTMLSelectElement>) => {
const { data, setData } = this.props; const { data, setData } = this.props;
setData({ setData({
...data, ...data,
board_profile: event.target.value, board_profile: event.target.value
}); });
if (event.target.value === "CUSTOM") return; if (event.target.value === 'CUSTOM') return;
this.setState({ processing: true }); this.setState({ processing: true });
redirectingAuthorizedFetch(BOARD_PROFILE_ENDPOINT, { redirectingAuthorizedFetch(BOARD_PROFILE_ENDPOINT, {
method: "POST", method: 'POST',
body: JSON.stringify({ code: event.target.value }), body: JSON.stringify({ code: event.target.value }),
headers: { headers: {
"Content-Type": "application/json", 'Content-Type': 'application/json'
}, }
}) })
.then((response) => { .then((response) => {
if (response.status === 200) { if (response.status === 200) {
return response.json(); return response.json();
} }
throw Error("Unexpected response code: " + response.status); throw Error('Unexpected response code: ' + response.status);
}) })
.then((json) => { .then((json) => {
this.props.enqueueSnackbar("Profile loaded", { variant: "success" }); this.props.enqueueSnackbar('Profile loaded', { variant: 'success' });
setData({ setData({
...data, ...data,
led_gpio: json.led_gpio, led_gpio: json.led_gpio,
@@ -89,14 +89,14 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
rx_gpio: json.rx_gpio, rx_gpio: json.rx_gpio,
tx_gpio: json.tx_gpio, tx_gpio: json.tx_gpio,
pbutton_gpio: json.pbutton_gpio, pbutton_gpio: json.pbutton_gpio,
board_profile: event.target.value, board_profile: event.target.value
}); });
this.setState({ processing: false }); this.setState({ processing: false });
}) })
.catch((error) => { .catch((error) => {
this.props.enqueueSnackbar( this.props.enqueueSnackbar(
error.message || "Problem fetching board profile", error.message || 'Problem fetching board profile',
{ variant: "warning" } { variant: 'warning' }
); );
this.setState({ processing: false }); this.setState({ processing: false });
}); });
@@ -108,13 +108,13 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
<ValidatorForm onSubmit={saveData}> <ValidatorForm onSubmit={saveData}>
<Box bgcolor="info.main" p={2} mt={2} mb={2}> <Box bgcolor="info.main" p={2} mt={2} mb={2}>
<Typography variant="body1"> <Typography variant="body1">
Adjust any of the EMS-ESP settings here. For help refer to the{" "} Adjust any of the EMS-ESP settings here. For help refer to the{' '}
<Link <Link
target="_blank" target="_blank"
href="https://emsesp.github.io/docs/#/Configure-firmware32?id=ems-esp-settings" href="https://emsesp.github.io/docs/#/Configure-firmware32?id=ems-esp-settings"
color="primary" color="primary"
> >
{"online documentation"} {'online documentation'}
</Link> </Link>
. .
</Typography> </Typography>
@@ -139,7 +139,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
value={data.tx_mode} value={data.tx_mode}
fullWidth fullWidth
variant="outlined" variant="outlined"
onChange={handleValueChange("tx_mode")} onChange={handleValueChange('tx_mode')}
margin="normal" margin="normal"
> >
<MenuItem value={0}>Off</MenuItem> <MenuItem value={0}>Off</MenuItem>
@@ -156,7 +156,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
value={data.ems_bus_id} value={data.ems_bus_id}
fullWidth fullWidth
variant="outlined" variant="outlined"
onChange={handleValueChange("ems_bus_id")} onChange={handleValueChange('ems_bus_id')}
margin="normal" margin="normal"
> >
<MenuItem value={0x0b}>Service Key (0x0B)</MenuItem> <MenuItem value={0x0b}>Service Key (0x0B)</MenuItem>
@@ -169,16 +169,16 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
<Grid item xs={6}> <Grid item xs={6}>
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:120", 'maxNumber:120'
]} ]}
errorMessages={[ errorMessages={[
"Tx delay is required", 'Tx delay is required',
"Must be a number", 'Must be a number',
"Must be 0 or higher", 'Must be 0 or higher',
"Max value is 120", 'Max value is 120'
]} ]}
name="tx_delay" name="tx_delay"
label="Tx start delay (seconds)" label="Tx start delay (seconds)"
@@ -186,7 +186,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.tx_delay} value={data.tx_delay}
type="number" type="number"
onChange={handleValueChange("tx_delay")} onChange={handleValueChange('tx_delay')}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
@@ -216,12 +216,12 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
margin="normal" margin="normal"
> >
{boardProfileSelectItems()} {boardProfileSelectItems()}
<MenuItem key={"CUSTOM"} value={"CUSTOM"}> <MenuItem key={'CUSTOM'} value={'CUSTOM'}>
Custom... Custom...
</MenuItem> </MenuItem>
</SelectValidator> </SelectValidator>
{data.board_profile === "CUSTOM" && ( {data.board_profile === 'CUSTOM' && (
<Grid <Grid
container container
spacing={1} spacing={1}
@@ -232,18 +232,18 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
<Grid item xs={4}> <Grid item xs={4}>
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:40", 'maxNumber:40',
"matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$", 'matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$'
]} ]}
errorMessages={[ errorMessages={[
"GPIO is required", 'GPIO is required',
"Must be a number", 'Must be a number',
"Must be 0 or higher", 'Must be 0 or higher',
"Max value is 40", 'Max value is 40',
"Not a valid GPIO", 'Not a valid GPIO'
]} ]}
name="rx_gpio" name="rx_gpio"
label="Rx GPIO" label="Rx GPIO"
@@ -251,25 +251,25 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.rx_gpio} value={data.rx_gpio}
type="number" type="number"
onChange={handleValueChange("rx_gpio")} onChange={handleValueChange('rx_gpio')}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={4}> <Grid item xs={4}>
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:40", 'maxNumber:40',
"matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$", 'matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$'
]} ]}
errorMessages={[ errorMessages={[
"GPIO is required", 'GPIO is required',
"Must be a number", 'Must be a number',
"Must be 0 or higher", 'Must be 0 or higher',
"Max value is 40", 'Max value is 40',
"Not a valid GPIO", 'Not a valid GPIO'
]} ]}
name="tx_gpio" name="tx_gpio"
label="Tx GPIO" label="Tx GPIO"
@@ -277,25 +277,25 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.tx_gpio} value={data.tx_gpio}
type="number" type="number"
onChange={handleValueChange("tx_gpio")} onChange={handleValueChange('tx_gpio')}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={4}> <Grid item xs={4}>
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:40", 'maxNumber:40',
"matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$", 'matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$'
]} ]}
errorMessages={[ errorMessages={[
"GPIO is required", 'GPIO is required',
"Must be a number", 'Must be a number',
"Must be 0 or higher", 'Must be 0 or higher',
"Max value is 40", 'Max value is 40',
"Not a valid GPIO", 'Not a valid GPIO'
]} ]}
name="pbutton_gpio" name="pbutton_gpio"
label="Button GPIO" label="Button GPIO"
@@ -303,25 +303,25 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.pbutton_gpio} value={data.pbutton_gpio}
type="number" type="number"
onChange={handleValueChange("pbutton_gpio")} onChange={handleValueChange('pbutton_gpio')}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={4}> <Grid item xs={4}>
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:40", 'maxNumber:40',
"matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$", 'matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$'
]} ]}
errorMessages={[ errorMessages={[
"GPIO is required", 'GPIO is required',
"Must be a number", 'Must be a number',
"Must be 0 or higher", 'Must be 0 or higher',
"Max value is 40", 'Max value is 40',
"Not a valid GPIO", 'Not a valid GPIO'
]} ]}
name="dallas_gpio" name="dallas_gpio"
label="Dallas GPIO (0=none)" label="Dallas GPIO (0=none)"
@@ -329,25 +329,25 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.dallas_gpio} value={data.dallas_gpio}
type="number" type="number"
onChange={handleValueChange("dallas_gpio")} onChange={handleValueChange('dallas_gpio')}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={4}> <Grid item xs={4}>
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:40", 'maxNumber:40',
"matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$", 'matchRegexp:^((?!6|7|8|9|10|11|12|14|15|20|24|28|29|30|31)[0-9]*)$'
]} ]}
errorMessages={[ errorMessages={[
"GPIO is required", 'GPIO is required',
"Must be a number", 'Must be a number',
"Must be 0 or higher", 'Must be 0 or higher',
"Max value is 40", 'Max value is 40',
"Not a valid GPIO", 'Not a valid GPIO'
]} ]}
name="led_gpio" name="led_gpio"
label="LED GPIO (0=none)" label="LED GPIO (0=none)"
@@ -355,7 +355,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.led_gpio} value={data.led_gpio}
type="number" type="number"
onChange={handleValueChange("led_gpio")} onChange={handleValueChange('led_gpio')}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
@@ -372,7 +372,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
control={ control={
<Checkbox <Checkbox
checked={data.hide_led} checked={data.hide_led}
onChange={handleValueChange("hide_led")} onChange={handleValueChange('hide_led')}
value="hide_led" value="hide_led"
/> />
} }
@@ -385,7 +385,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
control={ control={
<Checkbox <Checkbox
checked={data.dallas_parasite} checked={data.dallas_parasite}
onChange={handleValueChange("dallas_parasite")} onChange={handleValueChange('dallas_parasite')}
value="dallas_parasite" value="dallas_parasite"
/> />
} }
@@ -397,7 +397,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
control={ control={
<Checkbox <Checkbox
checked={data.notoken_api} checked={data.notoken_api}
onChange={handleValueChange("notoken_api")} onChange={handleValueChange('notoken_api')}
value="notoken_api" value="notoken_api"
/> />
} }
@@ -407,7 +407,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
control={ control={
<Checkbox <Checkbox
checked={data.analog_enabled} checked={data.analog_enabled}
onChange={handleValueChange("analog_enabled")} onChange={handleValueChange('analog_enabled')}
value="analog_enabled" value="analog_enabled"
/> />
} }
@@ -424,7 +424,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
control={ control={
<Checkbox <Checkbox
checked={data.shower_timer} checked={data.shower_timer}
onChange={handleValueChange("shower_timer")} onChange={handleValueChange('shower_timer')}
value="shower_timer" value="shower_timer"
/> />
} }
@@ -434,7 +434,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
control={ control={
<Checkbox <Checkbox
checked={data.shower_alert} checked={data.shower_alert}
onChange={handleValueChange("shower_alert")} onChange={handleValueChange('shower_alert')}
value="shower_alert" value="shower_alert"
/> />
} }
@@ -451,7 +451,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
control={ control={
<Checkbox <Checkbox
checked={data.syslog_enabled} checked={data.syslog_enabled}
onChange={handleValueChange("syslog_enabled")} onChange={handleValueChange('syslog_enabled')}
value="syslog_enabled" value="syslog_enabled"
/> />
} }
@@ -468,30 +468,30 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
> >
<Grid item xs={5}> <Grid item xs={5}>
<TextValidator <TextValidator
validators={["isOptionalIP"]} validators={['isOptionalIP']}
errorMessages={["Not a valid IP address"]} errorMessages={['Not a valid IP address']}
name="syslog_host" name="syslog_host"
label="IP" label="IP"
fullWidth fullWidth
variant="outlined" variant="outlined"
value={data.syslog_host} value={data.syslog_host}
onChange={handleValueChange("syslog_host")} onChange={handleValueChange('syslog_host')}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
<Grid item xs={6}> <Grid item xs={6}>
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:65535", 'maxNumber:65535'
]} ]}
errorMessages={[ errorMessages={[
"Port is required", 'Port is required',
"Must be a number", 'Must be a number',
"Must be greater than 0 ", 'Must be greater than 0 ',
"Max value is 65535", 'Max value is 65535'
]} ]}
name="syslog_port" name="syslog_port"
label="Port" label="Port"
@@ -499,7 +499,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.syslog_port} value={data.syslog_port}
type="number" type="number"
onChange={handleValueChange("syslog_port")} onChange={handleValueChange('syslog_port')}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
@@ -510,7 +510,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
value={data.syslog_level} value={data.syslog_level}
fullWidth fullWidth
variant="outlined" variant="outlined"
onChange={handleValueChange("syslog_level")} onChange={handleValueChange('syslog_level')}
margin="normal" margin="normal"
> >
<MenuItem value={-1}>OFF</MenuItem> <MenuItem value={-1}>OFF</MenuItem>
@@ -524,16 +524,16 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
<Grid item xs={6}> <Grid item xs={6}>
<TextValidator <TextValidator
validators={[ validators={[
"required", 'required',
"isNumber", 'isNumber',
"minNumber:0", 'minNumber:0',
"maxNumber:65535", 'maxNumber:65535'
]} ]}
errorMessages={[ errorMessages={[
"Syslog Mark is required", 'Syslog Mark is required',
"Must be a number", 'Must be a number',
"Must be 0 or higher", 'Must be 0 or higher',
"Max value is 10", 'Max value is 10'
]} ]}
name="syslog_mark_interval" name="syslog_mark_interval"
label="Mark Interval seconds (0=off)" label="Mark Interval seconds (0=off)"
@@ -541,7 +541,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
variant="outlined" variant="outlined"
value={data.syslog_mark_interval} value={data.syslog_mark_interval}
type="number" type="number"
onChange={handleValueChange("syslog_mark_interval")} onChange={handleValueChange('syslog_mark_interval')}
margin="normal" margin="normal"
/> />
</Grid> </Grid>
@@ -549,7 +549,7 @@ class EMSESPSettingsForm extends React.Component<EMSESPSettingsFormProps> {
control={ control={
<Checkbox <Checkbox
checked={data.trace_raw} checked={data.trace_raw}
onChange={handleValueChange("trace_raw")} onChange={handleValueChange('trace_raw')}
value="trace_raw" value="trace_raw"
/> />
} }

View File

@@ -1,39 +1,39 @@
import { Theme } from '@material-ui/core' import { Theme } from '@material-ui/core';
import { EMSESPStatus, busConnectionStatus } from './EMSESPtypes' import { EMSESPStatus, busConnectionStatus } from './EMSESPtypes';
export const isConnected = ({ status }: EMSESPStatus) => export const isConnected = ({ status }: EMSESPStatus) =>
status !== busConnectionStatus.BUS_STATUS_OFFLINE status !== busConnectionStatus.BUS_STATUS_OFFLINE;
export const busStatusHighlight = ({ status }: EMSESPStatus, theme: Theme) => { export const busStatusHighlight = ({ status }: EMSESPStatus, theme: Theme) => {
switch (status) { switch (status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS: case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main return theme.palette.warning.main;
case busConnectionStatus.BUS_STATUS_CONNECTED: case busConnectionStatus.BUS_STATUS_CONNECTED:
return theme.palette.success.main return theme.palette.success.main;
case busConnectionStatus.BUS_STATUS_OFFLINE: case busConnectionStatus.BUS_STATUS_OFFLINE:
return theme.palette.error.main return theme.palette.error.main;
default: default:
return theme.palette.warning.main return theme.palette.warning.main;
} }
} };
export const busStatus = ({ status }: EMSESPStatus) => { export const busStatus = ({ status }: EMSESPStatus) => {
switch (status) { switch (status) {
case busConnectionStatus.BUS_STATUS_CONNECTED: case busConnectionStatus.BUS_STATUS_CONNECTED:
return 'Connected' return 'Connected';
case busConnectionStatus.BUS_STATUS_TX_ERRORS: case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return 'Tx Errors' return 'Tx Errors';
case busConnectionStatus.BUS_STATUS_OFFLINE: case busConnectionStatus.BUS_STATUS_OFFLINE:
return 'Disconnected' return 'Disconnected';
default: default:
return 'Unknown' return 'Unknown';
} }
} };
export const qualityHighlight = (value: number, theme: Theme) => { export const qualityHighlight = (value: number, theme: Theme) => {
if (value >= 95) { if (value >= 95) {
return theme.palette.success.main return theme.palette.success.main;
} }
return theme.palette.error.main return theme.palette.error.main;
} };

View File

@@ -1,30 +1,34 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { ENDPOINT_ROOT } from '../api'; import { ENDPOINT_ROOT } from '../api';
import EMSESPStatusForm from './EMSESPStatusForm'; import EMSESPStatusForm from './EMSESPStatusForm';
import { EMSESPStatus } from './EMSESPtypes'; import { EMSESPStatus } from './EMSESPtypes';
export const EMSESP_STATUS_ENDPOINT = ENDPOINT_ROOT + "emsespStatus"; export const EMSESP_STATUS_ENDPOINT = ENDPOINT_ROOT + 'emsespStatus';
type EMSESPStatusControllerProps = RestControllerProps<EMSESPStatus>; type EMSESPStatusControllerProps = RestControllerProps<EMSESPStatus>;
class EMSESPStatusController extends Component<EMSESPStatusControllerProps> { class EMSESPStatusController extends Component<EMSESPStatusControllerProps> {
componentDidMount() {
this.props.loadData();
}
componentDidMount() { render() {
this.props.loadData(); return (
} <SectionContent title="EMS Status">
<RestFormLoader
render() { {...this.props}
return ( render={(formProps) => <EMSESPStatusForm {...formProps} />}
<SectionContent title="EMS Status"> />
<RestFormLoader </SectionContent>
{...this.props} );
render={formProps => <EMSESPStatusForm {...formProps} />} }
/>
</SectionContent>
)
}
} }
export default restController(EMSESP_STATUS_ENDPOINT, EMSESPStatusController); export default restController(EMSESP_STATUS_ENDPOINT, EMSESPStatusController);

View File

@@ -1,6 +1,6 @@
import React, { Component, Fragment } from "react"; import React, { Component, Fragment } from 'react';
import { WithTheme, withTheme } from "@material-ui/core/styles"; import { WithTheme, withTheme } from '@material-ui/core/styles';
import { import {
TableContainer, TableContainer,
Table, Table,
@@ -13,35 +13,32 @@ import {
ListItemText, ListItemText,
withWidth, withWidth,
WithWidthProps, WithWidthProps,
isWidthDown, isWidthDown
} from "@material-ui/core"; } from '@material-ui/core';
import RefreshIcon from "@material-ui/icons/Refresh"; import RefreshIcon from '@material-ui/icons/Refresh';
import DeviceHubIcon from "@material-ui/icons/DeviceHub"; import DeviceHubIcon from '@material-ui/icons/DeviceHub';
import { import {
RestFormProps, RestFormProps,
FormActions, FormActions,
FormButton, FormButton,
HighlightAvatar, HighlightAvatar
} from "../components"; } from '../components';
import { import { busStatus, busStatusHighlight, isConnected } from './EMSESPStatus';
busStatus,
busStatusHighlight,
isConnected,
} from "./EMSESPStatus";
import { EMSESPStatus } from "./EMSESPtypes"; import { EMSESPStatus } from './EMSESPtypes';
function formatNumber(num: number) { function formatNumber(num: number) {
return new Intl.NumberFormat().format(num); return new Intl.NumberFormat().format(num);
} }
type EMSESPStatusFormProps = RestFormProps<EMSESPStatus> & WithTheme & WithWidthProps; type EMSESPStatusFormProps = RestFormProps<EMSESPStatus> &
WithTheme &
WithWidthProps;
class EMSESPStatusForm extends Component<EMSESPStatusFormProps> { class EMSESPStatusForm extends Component<EMSESPStatusFormProps> {
createListItems() { createListItems() {
const { data, theme, width } = this.props; const { data, theme, width } = this.props;
return ( return (
@@ -52,24 +49,30 @@ class EMSESPStatusForm extends Component<EMSESPStatusFormProps> {
<DeviceHubIcon /> <DeviceHubIcon />
</HighlightAvatar> </HighlightAvatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary="Connection Status" secondary={busStatus(data)} /> <ListItemText
primary="Connection Status"
secondary={busStatus(data)}
/>
</ListItem> </ListItem>
{isConnected(data) && ( {isConnected(data) && (
<TableContainer> <TableContainer>
<Table size="small" padding={isWidthDown('xs', width!) ? "none" : "default"}> <Table
size="small"
padding={isWidthDown('xs', width!) ? 'none' : 'default'}
>
<TableBody> <TableBody>
<TableRow> <TableRow>
<TableCell> <TableCell># Telegrams Received</TableCell>
# Telegrams Received <TableCell align="right">
</TableCell> {formatNumber(data.rx_received)}&nbsp;(quality{' '}
<TableCell align="right">{formatNumber(data.rx_received)}&nbsp;(quality {data.rx_quality}%) {data.rx_quality}%)
</TableCell> </TableCell>
</TableRow> </TableRow>
<TableRow> <TableRow>
<TableCell > <TableCell># Telegrams Sent</TableCell>
# Telegrams Sent <TableCell align="right">
</TableCell > {formatNumber(data.tx_sent)}&nbsp;(quality {data.tx_quality}
<TableCell align="right">{formatNumber(data.tx_sent)}&nbsp;(quality {data.tx_quality}%) %)
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableBody> </TableBody>
@@ -86,7 +89,11 @@ class EMSESPStatusForm extends Component<EMSESPStatusFormProps> {
<List>{this.createListItems()}</List> <List>{this.createListItems()}</List>
<FormActions> <FormActions>
<FormButton <FormButton
startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={this.props.loadData}> startIcon={<RefreshIcon />}
variant="contained"
color="secondary"
onClick={this.props.loadData}
>
Refresh Refresh
</FormButton> </FormButton>
</FormActions> </FormActions>

View File

@@ -1,72 +1,72 @@
export interface EMSESPSettings { export interface EMSESPSettings {
tx_mode: number tx_mode: number;
tx_delay: number tx_delay: number;
ems_bus_id: number ems_bus_id: number;
syslog_enabled: boolean syslog_enabled: boolean;
syslog_level: number syslog_level: number;
syslog_mark_interval: number syslog_mark_interval: number;
syslog_host: string syslog_host: string;
syslog_port: number syslog_port: number;
master_thermostat: number master_thermostat: number;
shower_timer: boolean shower_timer: boolean;
shower_alert: boolean shower_alert: boolean;
rx_gpio: number rx_gpio: number;
tx_gpio: number tx_gpio: number;
dallas_gpio: number dallas_gpio: number;
dallas_parasite: boolean dallas_parasite: boolean;
led_gpio: number led_gpio: number;
hide_led: boolean hide_led: boolean;
notoken_api: boolean notoken_api: boolean;
analog_enabled: boolean analog_enabled: boolean;
pbutton_gpio: number pbutton_gpio: number;
trace_raw: boolean trace_raw: boolean;
board_profile: string board_profile: string;
} }
export enum busConnectionStatus { export enum busConnectionStatus {
BUS_STATUS_CONNECTED = 0, BUS_STATUS_CONNECTED = 0,
BUS_STATUS_TX_ERRORS = 1, BUS_STATUS_TX_ERRORS = 1,
BUS_STATUS_OFFLINE = 2, BUS_STATUS_OFFLINE = 2
} }
export interface EMSESPStatus { export interface EMSESPStatus {
status: busConnectionStatus status: busConnectionStatus;
rx_received: number rx_received: number;
tx_sent: number tx_sent: number;
rx_quality: number rx_quality: number;
tx_quality: number tx_quality: number;
} }
export interface Device { export interface Device {
id: number id: number;
type: string type: string;
brand: string brand: string;
name: string name: string;
deviceid: number deviceid: number;
productid: number productid: number;
version: string version: string;
} }
export interface Sensor { export interface Sensor {
no: number no: number;
id: string id: string;
temp: string temp: string;
} }
export interface EMSESPDevices { export interface EMSESPDevices {
devices: Device[] devices: Device[];
sensors: Sensor[] sensors: Sensor[];
} }
export interface EMSESPDeviceData { export interface EMSESPDeviceData {
name: string name: string;
data: string[] data: string[];
} }
export interface DeviceValue { export interface DeviceValue {
id: number id: number;
data: string data: string;
uom: string uom: string;
name: string name: string;
cmd: string cmd: string;
} }

View File

@@ -1,12 +1,15 @@
import React, { Component } from "react"; import { Component } from 'react';
import { Link, withRouter, RouteComponentProps } from "react-router-dom"; import { Link, withRouter, RouteComponentProps } from 'react-router-dom';
import { List, ListItem, ListItemIcon, ListItemText } from "@material-ui/core"; import { List, ListItem, ListItemIcon, ListItemText } from '@material-ui/core';
import TuneIcon from '@material-ui/icons/Tune'; import TuneIcon from '@material-ui/icons/Tune';
import DashboardIcon from "@material-ui/icons/Dashboard"; import DashboardIcon from '@material-ui/icons/Dashboard';
import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication'; import {
withAuthenticatedContext,
AuthenticatedContextProps
} from '../authentication';
type ProjectProps = AuthenticatedContextProps & RouteComponentProps; type ProjectProps = AuthenticatedContextProps & RouteComponentProps;
@@ -16,13 +19,28 @@ class ProjectMenu extends Component<ProjectProps> {
const path = this.props.match.url; const path = this.props.match.url;
return ( return (
<List> <List>
<ListItem to='/ems-esp/' selected={path.startsWith('/ems-esp/status') || path.startsWith('/ems-esp/devices') || path.startsWith('/ems-esp/help')} button component={Link}> <ListItem
to="/ems-esp/"
selected={
path.startsWith('/ems-esp/status') ||
path.startsWith('/ems-esp/devices') ||
path.startsWith('/ems-esp/help')
}
button
component={Link}
>
<ListItemIcon> <ListItemIcon>
<DashboardIcon /> <DashboardIcon />
</ListItemIcon> </ListItemIcon>
<ListItemText primary="Dashboard" /> <ListItemText primary="Dashboard" />
</ListItem> </ListItem>
<ListItem to='/ems-esp/settings' selected={path.startsWith('/ems-esp/settings')} button component={Link} disabled={!authenticatedContext.me.admin}> <ListItem
to="/ems-esp/settings"
selected={path.startsWith('/ems-esp/settings')}
button
component={Link}
disabled={!authenticatedContext.me.admin}
>
<ListItemIcon> <ListItemIcon>
<TuneIcon /> <TuneIcon />
</ListItemIcon> </ListItemIcon>

View File

@@ -1,4 +1,4 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { Redirect, Switch } from 'react-router'; import { Redirect, Switch } from 'react-router';
import { AuthenticatedRoute } from '../authentication'; import { AuthenticatedRoute } from '../authentication';
@@ -7,24 +7,32 @@ import EMSESPDashboard from './EMSESPDashboard';
import EMSESPSettings from './EMSESPSettings'; import EMSESPSettings from './EMSESPSettings';
class ProjectRouting extends Component { class ProjectRouting extends Component {
render() { render() {
return ( return (
<Switch> <Switch>
<AuthenticatedRoute exact path="/ems-esp/status/*" component={EMSESPDashboard} /> <AuthenticatedRoute
<AuthenticatedRoute exact path="/ems-esp/settings" component={EMSESPSettings} /> exact
<AuthenticatedRoute exact path="/ems-esp/*" component={EMSESPDashboard} /> path="/ems-esp/status/*"
{ component={EMSESPDashboard}
/* />
* The redirect below caters for the default project route and redirecting invalid paths. <AuthenticatedRoute
* The "to" property must match one of the routes above for this to work correctly. exact
*/ path="/ems-esp/settings"
} component={EMSESPSettings}
/>
<AuthenticatedRoute
exact
path="/ems-esp/*"
component={EMSESPDashboard}
/>
{/*
* 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={`/ems-esp/status`} /> <Redirect to={`/ems-esp/status`} />
</Switch> </Switch>
) );
} }
} }
export default ProjectRouting; export default ProjectRouting;

View File

@@ -1,64 +1,100 @@
import React, { RefObject } from 'react'; import React, { RefObject } from 'react';
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator'; import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import { Dialog, DialogTitle, DialogContent, DialogActions, Box, Typography } from '@material-ui/core'; import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Box,
Typography
} from '@material-ui/core';
import { FormButton } from '../components'; import { FormButton } from '../components';
import { DeviceValue } from './EMSESPtypes'; import { DeviceValue } from './EMSESPtypes';
interface ValueFormProps { interface ValueFormProps {
devicevalue: DeviceValue; devicevalue: DeviceValue;
onDoneEditing: () => void; onDoneEditing: () => void;
onCancelEditing: () => void; onCancelEditing: () => void;
handleValueChange: (data: keyof DeviceValue) => (event: React.ChangeEvent<HTMLInputElement>) => void; handleValueChange: (
data: keyof DeviceValue
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
} }
class ValueForm extends React.Component<ValueFormProps> { class ValueForm extends React.Component<ValueFormProps> {
formRef: RefObject<any> = React.createRef();
formRef: RefObject<any> = React.createRef(); submit = () => {
this.formRef.current.submit();
};
submit = () => { buildLabel = (devicevalue: DeviceValue) => {
this.formRef.current.submit(); if (devicevalue.uom === '' || !devicevalue.uom) {
return 'New value';
} }
return 'New value (' + devicevalue.uom + ')';
};
buildLabel = (devicevalue: DeviceValue) => { render() {
if ((devicevalue.uom === "") || (!devicevalue.uom)) { const {
return "New value"; devicevalue,
} handleValueChange,
return "New value (" + devicevalue.uom + ")"; onDoneEditing,
} onCancelEditing
} = this.props;
return (
<ValidatorForm onSubmit={onDoneEditing} ref={this.formRef}>
<Dialog
maxWidth="xs"
onClose={onCancelEditing}
aria-labelledby="user-form-dialog-title"
open
>
<DialogTitle id="user-form-dialog-title">
Change the {devicevalue.name}
</DialogTitle>
<DialogContent dividers>
<TextValidator
validators={['required']}
errorMessages={['is required']}
name="data"
label={this.buildLabel(devicevalue)}
fullWidth
variant="outlined"
value={devicevalue.data}
margin="normal"
onChange={handleValueChange('data')}
/>
<Box color="warning.main" p={1} pl={0} pr={0} mt={0} mb={0}>
<Typography variant="body2">
<i>
Note: it may take a few seconds before the change is visible.
If nothing happens check the logs.
</i>
</Typography>
</Box>
</DialogContent>
render() { <DialogActions>
const { devicevalue, handleValueChange, onDoneEditing, onCancelEditing } = this.props; <FormButton
return ( variant="contained"
<ValidatorForm onSubmit={onDoneEditing} ref={this.formRef}> color="secondary"
<Dialog maxWidth="xs" onClose={onCancelEditing} aria-labelledby="user-form-dialog-title" open> onClick={onCancelEditing}
<DialogTitle id="user-form-dialog-title">Change the {devicevalue.name}</DialogTitle> >
<DialogContent dividers> Cancel
<TextValidator </FormButton>
validators={['required']} <FormButton
errorMessages={['is required']} variant="contained"
name="data" color="primary"
label={this.buildLabel(devicevalue)} type="submit"
fullWidth onClick={this.submit}
variant="outlined" >
value={devicevalue.data} Done
margin="normal" </FormButton>
onChange={handleValueChange('data')} </DialogActions>
/> </Dialog>
<Box color="warning.main" p={1} pl={0} pr={0} mt={0} mb={0}> </ValidatorForm>
<Typography variant="body2"> );
<i>Note: it may take a few seconds before the change is visible. If nothing happens check the logs.</i> }
</Typography>
</Box>
</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 ValueForm; export default ValueForm;

View File

@@ -1,5 +1,14 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { Dialog, DialogTitle, DialogContent, DialogActions, Box, LinearProgress, Typography, TextField } from '@material-ui/core'; import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Box,
LinearProgress,
Typography,
TextField
} from '@material-ui/core';
import { FormButton } from '../components'; import { FormButton } from '../components';
import { redirectingAuthorizedFetch } from '../authentication'; import { redirectingAuthorizedFetch } from '../authentication';
@@ -7,71 +16,105 @@ import { GENERATE_TOKEN_ENDPOINT } from '../api';
import { withSnackbar, WithSnackbarProps } from 'notistack'; import { withSnackbar, WithSnackbarProps } from 'notistack';
interface GenerateTokenProps extends WithSnackbarProps { interface GenerateTokenProps extends WithSnackbarProps {
username: string; username: string;
onClose: () => void; onClose: () => void;
} }
interface GenerateTokenState { interface GenerateTokenState {
token?: string; token?: string;
} }
class GenerateToken extends React.Component<GenerateTokenProps, GenerateTokenState> { class GenerateToken extends React.Component<
GenerateTokenProps,
GenerateTokenState
> {
state: GenerateTokenState = {};
state: GenerateTokenState = {}; componentDidMount() {
const { username } = this.props;
componentDidMount() { redirectingAuthorizedFetch(
const { username } = this.props; GENERATE_TOKEN_ENDPOINT + '?' + new URLSearchParams({ username }),
redirectingAuthorizedFetch(GENERATE_TOKEN_ENDPOINT + "?" + new URLSearchParams({ username }), { method: 'GET' }) { method: 'GET' }
.then(response => { )
if (response.status === 200) { .then((response) => {
return response.json(); if (response.status === 200) {
} else { return response.json();
throw Error("Error generating token: " + response.status); } else {
} throw Error('Error generating token: ' + response.status);
}).then(generatedToken => { }
console.log(generatedToken); })
this.setState({ token: generatedToken.token }); .then((generatedToken) => {
}) console.log(generatedToken);
.catch(error => { this.setState({ token: generatedToken.token });
this.props.enqueueSnackbar(error.message || "Problem generating token", { variant: 'error' }); })
}); .catch((error) => {
} this.props.enqueueSnackbar(
error.message || 'Problem generating token',
render() { { variant: 'error' }
const { onClose, username } = this.props;
const { token } = this.state;
return (
<Dialog onClose={onClose} aria-labelledby="generate-token-dialog-title" open fullWidth maxWidth="sm">
<DialogTitle id="generate-token-dialog-title">Token for: {username}</DialogTitle>
<DialogContent dividers>
{token ?
<Fragment>
<Box bgcolor="primary.main" color="primary.contrastText" p={2} mt={2} mb={2}>
<Typography variant="body1">
The token below may be used to access the secured APIs, either as a Bearer authentication in the "Authorization" header or using the "access_token" query parameter.
</Typography>
</Box>
<Box mt={2} mb={2}>
<TextField label="Token" multiline value={token} fullWidth contentEditable={false} />
</Box>
</Fragment>
:
<Box m={4} textAlign="center">
<LinearProgress />
<Typography variant="h6">
Generating token&hellip;
</Typography>
</Box>
}
</DialogContent>
<DialogActions>
<FormButton variant="contained" color="primary" type="submit" onClick={onClose}>
Close
</FormButton>
</DialogActions>
</Dialog>
); );
} });
}
render() {
const { onClose, username } = this.props;
const { token } = this.state;
return (
<Dialog
onClose={onClose}
aria-labelledby="generate-token-dialog-title"
open
fullWidth
maxWidth="sm"
>
<DialogTitle id="generate-token-dialog-title">
Token for: {username}
</DialogTitle>
<DialogContent dividers>
{token ? (
<Fragment>
<Box
bgcolor="primary.main"
color="primary.contrastText"
p={2}
mt={2}
mb={2}
>
<Typography variant="body1">
The token below may be used to access the secured APIs, either
as a Bearer authentication in the "Authorization" header or
using the "access_token" query parameter.
</Typography>
</Box>
<Box mt={2} mb={2}>
<TextField
label="Token"
multiline
value={token}
fullWidth
contentEditable={false}
/>
</Box>
</Fragment>
) : (
<Box m={4} textAlign="center">
<LinearProgress />
<Typography variant="h6">Generating token&hellip;</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<FormButton
variant="contained"
color="primary"
type="submit"
onClick={onClose}
>
Close
</FormButton>
</DialogActions>
</Dialog>
);
}
} }
export default withSnackbar(GenerateToken); export default withSnackbar(GenerateToken);

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { SECURITY_SETTINGS_ENDPOINT } from '../api'; import { SECURITY_SETTINGS_ENDPOINT } from '../api';
import ManageUsersForm from './ManageUsersForm'; import ManageUsersForm from './ManageUsersForm';
@@ -9,7 +14,6 @@ import { SecuritySettings } from './types';
type ManageUsersControllerProps = RestControllerProps<SecuritySettings>; type ManageUsersControllerProps = RestControllerProps<SecuritySettings>;
class ManageUsersController extends Component<ManageUsersControllerProps> { class ManageUsersController extends Component<ManageUsersControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -19,12 +23,14 @@ class ManageUsersController extends Component<ManageUsersControllerProps> {
<SectionContent title="Manage Users" titleGutter> <SectionContent title="Manage Users" titleGutter>
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <ManageUsersForm {...formProps} />} render={(formProps) => <ManageUsersForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
) );
} }
} }
export default restController(SECURITY_SETTINGS_ENDPOINT, ManageUsersController); export default restController(
SECURITY_SETTINGS_ENDPOINT,
ManageUsersController
);

View File

@@ -1,8 +1,18 @@
import React, { Fragment } from 'react'; import React, { Fragment } from 'react';
import { ValidatorForm } from 'react-material-ui-form-validator'; import { ValidatorForm } from 'react-material-ui-form-validator';
import { Table, TableBody, TableCell, TableHead, TableFooter, TableRow, withWidth, WithWidthProps, isWidthDown } from '@material-ui/core'; import {
import { Box, Button, Typography, } from '@material-ui/core'; 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 EditIcon from '@material-ui/icons/Edit';
import DeleteIcon from '@material-ui/icons/Delete'; import DeleteIcon from '@material-ui/icons/Delete';
@@ -13,8 +23,16 @@ import SaveIcon from '@material-ui/icons/Save';
import PersonAddIcon from '@material-ui/icons/PersonAdd'; import PersonAddIcon from '@material-ui/icons/PersonAdd';
import VpnKeyIcon from '@material-ui/icons/VpnKey'; import VpnKeyIcon from '@material-ui/icons/VpnKey';
import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication'; import {
import { RestFormProps, FormActions, FormButton, extractEventValue } from '../components'; withAuthenticatedContext,
AuthenticatedContextProps
} from '../authentication';
import {
RestFormProps,
FormActions,
FormButton,
extractEventValue
} from '../components';
import UserForm from './UserForm'; import UserForm from './UserForm';
import { SecuritySettings, User } from './types'; import { SecuritySettings, User } from './types';
@@ -30,16 +48,20 @@ function compareUsers(a: User, b: User) {
return 0; return 0;
} }
type ManageUsersFormProps = RestFormProps<SecuritySettings> & AuthenticatedContextProps & WithWidthProps; type ManageUsersFormProps = RestFormProps<SecuritySettings> &
AuthenticatedContextProps &
WithWidthProps;
type ManageUsersFormState = { type ManageUsersFormState = {
creating: boolean; creating: boolean;
user?: User; user?: User;
generateTokenFor?: string; generateTokenFor?: string;
} };
class ManageUsersForm extends React.Component<ManageUsersFormProps, ManageUsersFormState> {
class ManageUsersForm extends React.Component<
ManageUsersFormProps,
ManageUsersFormState
> {
state: ManageUsersFormState = { state: ManageUsersFormState = {
creating: false creating: false
}; };
@@ -48,38 +70,38 @@ class ManageUsersForm extends React.Component<ManageUsersFormProps, ManageUsersF
this.setState({ this.setState({
creating: true, creating: true,
user: { user: {
username: "", username: '',
password: "", password: '',
admin: true admin: true
} }
}); });
}; };
uniqueUsername = (username: string) => { uniqueUsername = (username: string) => {
return !this.props.data.users.find(u => u.username === username); return !this.props.data.users.find((u) => u.username === username);
} };
noAdminConfigured = () => { noAdminConfigured = () => {
return !this.props.data.users.find(u => u.admin); return !this.props.data.users.find((u) => u.admin);
} };
removeUser = (user: User) => { removeUser = (user: User) => {
const { data } = this.props; const { data } = this.props;
const users = data.users.filter(u => u.username !== user.username); const users = data.users.filter((u) => u.username !== user.username);
this.props.setData({ ...data, users }); this.props.setData({ ...data, users });
} };
closeGenerateToken = () => { closeGenerateToken = () => {
this.setState({ this.setState({
generateTokenFor: undefined generateTokenFor: undefined
}); });
} };
generateToken = (user: User) => { generateToken = (user: User) => {
this.setState({ this.setState({
generateTokenFor: user.username generateTokenFor: user.username
}); });
} };
startEditingUser = (user: User) => { startEditingUser = (user: User) => {
this.setState({ this.setState({
@@ -92,13 +114,13 @@ class ManageUsersForm extends React.Component<ManageUsersFormProps, ManageUsersF
this.setState({ this.setState({
user: undefined user: undefined
}); });
} };
doneEditingUser = () => { doneEditingUser = () => {
const { user } = this.state; const { user } = this.state;
if (user) { if (user) {
const { data } = this.props; const { data } = this.props;
const users = data.users.filter(u => u.username !== user.username); const users = data.users.filter((u) => u.username !== user.username);
users.push(user); users.push(user);
this.props.setData({ ...data, users }); this.props.setData({ ...data, users });
this.setState({ this.setState({
@@ -107,14 +129,18 @@ class ManageUsersForm extends React.Component<ManageUsersFormProps, ManageUsersF
} }
}; };
handleUserValueChange = (name: keyof User) => (event: React.ChangeEvent<HTMLInputElement>) => { handleUserValueChange = (name: keyof User) => (
this.setState({ user: { ...this.state.user!, [name]: extractEventValue(event) } }); event: React.ChangeEvent<HTMLInputElement>
) => {
this.setState({
user: { ...this.state.user!, [name]: extractEventValue(event) }
});
}; };
onSubmit = () => { onSubmit = () => {
this.props.saveData(); this.props.saveData();
this.props.authenticatedContext.refresh(); this.props.authenticatedContext.refresh();
} };
render() { render() {
const { width, data } = this.props; const { width, data } = this.props;
@@ -122,7 +148,10 @@ class ManageUsersForm extends React.Component<ManageUsersFormProps, ManageUsersF
return ( return (
<Fragment> <Fragment>
<ValidatorForm onSubmit={this.onSubmit}> <ValidatorForm onSubmit={this.onSubmit}>
<Table size="small" padding={isWidthDown('xs', width!) ? "none" : "default"}> <Table
size="small"
padding={isWidthDown('xs', width!) ? 'none' : 'default'}
>
<TableHead> <TableHead>
<TableRow> <TableRow>
<TableCell>Username</TableCell> <TableCell>Username</TableCell>
@@ -131,7 +160,7 @@ class ManageUsersForm extends React.Component<ManageUsersFormProps, ManageUsersF
</TableRow> </TableRow>
</TableHead> </TableHead>
<TableBody> <TableBody>
{data.users.sort(compareUsers).map(user => ( {data.users.sort(compareUsers).map((user) => (
<TableRow key={user.username}> <TableRow key={user.username}>
<TableCell component="th" scope="row"> <TableCell component="th" scope="row">
{user.username} {user.username}
@@ -140,51 +169,79 @@ class ManageUsersForm extends React.Component<ManageUsersFormProps, ManageUsersF
{user.admin ? <CheckIcon /> : <CloseIcon />} {user.admin ? <CheckIcon /> : <CloseIcon />}
</TableCell> </TableCell>
<TableCell align="center"> <TableCell align="center">
<IconButton size="small" aria-label="Generate Token" onClick={() => this.generateToken(user)}> <IconButton
size="small"
aria-label="Generate Token"
onClick={() => this.generateToken(user)}
>
<VpnKeyIcon /> <VpnKeyIcon />
</IconButton> </IconButton>
<IconButton size="small" aria-label="Delete" onClick={() => this.removeUser(user)}> <IconButton
size="small"
aria-label="Delete"
onClick={() => this.removeUser(user)}
>
<DeleteIcon /> <DeleteIcon />
</IconButton> </IconButton>
<IconButton size="small" aria-label="Edit" onClick={() => this.startEditingUser(user)}> <IconButton
size="small"
aria-label="Edit"
onClick={() => this.startEditingUser(user)}
>
<EditIcon /> <EditIcon />
</IconButton> </IconButton>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
<TableFooter > <TableFooter>
<TableRow> <TableRow>
<TableCell colSpan={2} /> <TableCell colSpan={2} />
<TableCell align="center" padding="default"> <TableCell align="center" padding="default">
<Button startIcon={<PersonAddIcon />} variant="contained" color="secondary" onClick={this.createUser}> <Button
startIcon={<PersonAddIcon />}
variant="contained"
color="secondary"
onClick={this.createUser}
>
Add Add
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
</TableFooter> </TableFooter>
</Table> </Table>
{ {this.noAdminConfigured() && (
this.noAdminConfigured() && <Box
( bgcolor="error.main"
<Box bgcolor="error.main" color="error.contrastText" p={2} mt={2} mb={2}> color="error.contrastText"
<Typography variant="body1"> p={2}
You must have at least one admin user configured. mt={2}
</Typography> mb={2}
</Box> >
) <Typography variant="body1">
} You must have at least one admin user configured.
</Typography>
</Box>
)}
<FormActions> <FormActions>
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit" disabled={this.noAdminConfigured()}> <FormButton
startIcon={<SaveIcon />}
variant="contained"
color="primary"
type="submit"
disabled={this.noAdminConfigured()}
>
Save Save
</FormButton> </FormButton>
</FormActions> </FormActions>
</ValidatorForm> </ValidatorForm>
{ {generateTokenFor && (
generateTokenFor && <GenerateToken username={generateTokenFor} onClose={this.closeGenerateToken} /> <GenerateToken
} username={generateTokenFor}
{ onClose={this.closeGenerateToken}
user && />
)}
{user && (
<UserForm <UserForm
user={user} user={user}
creating={creating} creating={creating}
@@ -193,11 +250,10 @@ class ManageUsersForm extends React.Component<ManageUsersFormProps, ManageUsersF
handleValueChange={this.handleUserValueChange} handleValueChange={this.handleUserValueChange}
uniqueUsername={this.uniqueUsername} uniqueUsername={this.uniqueUsername}
/> />
} )}
</Fragment> </Fragment>
); );
} }
} }
export default withAuthenticatedContext(withWidth()(ManageUsersForm)); export default withAuthenticatedContext(withWidth()(ManageUsersForm));

View File

@@ -1,9 +1,12 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
import { Tabs, Tab } from '@material-ui/core'; import { Tabs, Tab } from '@material-ui/core';
import { AuthenticatedContextProps, AuthenticatedRoute } from '../authentication'; import {
AuthenticatedContextProps,
AuthenticatedRoute
} from '../authentication';
import { MenuAppBar } from '../components'; import { MenuAppBar } from '../components';
import ManageUsersController from './ManageUsersController'; import ManageUsersController from './ManageUsersController';
@@ -12,25 +15,36 @@ import SecuritySettingsController from './SecuritySettingsController';
type SecurityProps = AuthenticatedContextProps & RouteComponentProps; type SecurityProps = AuthenticatedContextProps & RouteComponentProps;
class Security extends Component<SecurityProps> { class Security extends Component<SecurityProps> {
handleTabChange = (path: string) => {
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
this.props.history.push(path); this.props.history.push(path);
}; };
render() { render() {
return ( return (
<MenuAppBar sectionTitle="Security"> <MenuAppBar sectionTitle="Security">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> <Tabs
value={this.props.match.url}
onChange={(e, path) => this.handleTabChange(path)}
variant="fullWidth"
>
<Tab value="/security/users" label="Manage Users" /> <Tab value="/security/users" label="Manage Users" />
<Tab value="/security/settings" label="Security Settings" /> <Tab value="/security/settings" label="Security Settings" />
</Tabs> </Tabs>
<Switch> <Switch>
<AuthenticatedRoute exact path="/security/users" component={ManageUsersController} /> <AuthenticatedRoute
<AuthenticatedRoute exact path="/security/settings" component={SecuritySettingsController} /> exact
path="/security/users"
component={ManageUsersController}
/>
<AuthenticatedRoute
exact
path="/security/settings"
component={SecuritySettingsController}
/>
<Redirect to="/security/users" /> <Redirect to="/security/users" />
</Switch> </Switch>
</MenuAppBar> </MenuAppBar>
) );
} }
} }

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { SECURITY_SETTINGS_ENDPOINT } from '../api'; import { SECURITY_SETTINGS_ENDPOINT } from '../api';
import SecuritySettingsForm from './SecuritySettingsForm'; import SecuritySettingsForm from './SecuritySettingsForm';
@@ -9,7 +14,6 @@ import { SecuritySettings } from './types';
type SecuritySettingsControllerProps = RestControllerProps<SecuritySettings>; type SecuritySettingsControllerProps = RestControllerProps<SecuritySettings>;
class SecuritySettingsController extends Component<SecuritySettingsControllerProps> { class SecuritySettingsController extends Component<SecuritySettingsControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -19,12 +23,14 @@ class SecuritySettingsController extends Component<SecuritySettingsControllerPro
<SectionContent title="Security Settings" titleGutter> <SectionContent title="Security Settings" titleGutter>
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <SecuritySettingsForm {...formProps} />} render={(formProps) => <SecuritySettingsForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
); );
} }
} }
export default restController(SECURITY_SETTINGS_ENDPOINT, SecuritySettingsController); export default restController(
SECURITY_SETTINGS_ENDPOINT,
SecuritySettingsController
);

View File

@@ -4,19 +4,27 @@ import { ValidatorForm } from 'react-material-ui-form-validator';
import { Box, Typography } from '@material-ui/core'; import { Box, Typography } from '@material-ui/core';
import SaveIcon from '@material-ui/icons/Save'; import SaveIcon from '@material-ui/icons/Save';
import { withAuthenticatedContext, AuthenticatedContextProps } from '../authentication'; import {
import { RestFormProps, PasswordValidator, FormActions, FormButton } from '../components'; withAuthenticatedContext,
AuthenticatedContextProps
} from '../authentication';
import {
RestFormProps,
PasswordValidator,
FormActions,
FormButton
} from '../components';
import { SecuritySettings } from './types'; import { SecuritySettings } from './types';
type SecuritySettingsFormProps = RestFormProps<SecuritySettings> & AuthenticatedContextProps; type SecuritySettingsFormProps = RestFormProps<SecuritySettings> &
AuthenticatedContextProps;
class SecuritySettingsForm extends React.Component<SecuritySettingsFormProps> { class SecuritySettingsForm extends React.Component<SecuritySettingsFormProps> {
onSubmit = () => { onSubmit = () => {
this.props.saveData(); this.props.saveData();
this.props.authenticatedContext.refresh(); this.props.authenticatedContext.refresh();
} };
render() { render() {
const { data, handleValueChange } = this.props; const { data, handleValueChange } = this.props;
@@ -24,7 +32,10 @@ class SecuritySettingsForm extends React.Component<SecuritySettingsFormProps> {
<ValidatorForm onSubmit={this.onSubmit}> <ValidatorForm onSubmit={this.onSubmit}>
<PasswordValidator <PasswordValidator
validators={['required', 'matchRegexp:^.{1,64}$']} validators={['required', 'matchRegexp:^.{1,64}$']}
errorMessages={['Password Required', 'Password must be 64 characters or less']} errorMessages={[
'Password Required',
'Password must be 64 characters or less'
]}
name="jwt_secret" name="jwt_secret"
label="Super User Password" label="Super User Password"
fullWidth fullWidth
@@ -33,20 +44,32 @@ class SecuritySettingsForm extends React.Component<SecuritySettingsFormProps> {
onChange={handleValueChange('jwt_secret')} onChange={handleValueChange('jwt_secret')}
margin="normal" margin="normal"
/> />
<Box bgcolor="primary.main" color="primary.contrastText" p={2} mt={2} mb={2}> <Box
bgcolor="primary.main"
color="primary.contrastText"
p={2}
mt={2}
mb={2}
>
<Typography variant="body1"> <Typography variant="body1">
The Super User password is used to sign authentication tokens and is also the Console's `su` password. If you modify this all users will be signed out. The Super User password is used to sign authentication tokens and is
also the Console's `su` password. If you modify this all users will
be signed out.
</Typography> </Typography>
</Box> </Box>
<FormActions> <FormActions>
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> <FormButton
startIcon={<SaveIcon />}
variant="contained"
color="primary"
type="submit"
>
Save Save
</FormButton> </FormButton>
</FormActions> </FormActions>
</ValidatorForm> </ValidatorForm>
); );
} }
} }
export default withAuthenticatedContext(SecuritySettingsForm); export default withAuthenticatedContext(SecuritySettingsForm);

View File

@@ -1,9 +1,19 @@
import React, { RefObject } from 'react'; import React, { RefObject } from 'react';
import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator'; import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import { Dialog, DialogTitle, DialogContent, DialogActions, Checkbox } from '@material-ui/core'; import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Checkbox
} from '@material-ui/core';
import { PasswordValidator, BlockFormControlLabel, FormButton } from '../components'; import {
PasswordValidator,
BlockFormControlLabel,
FormButton
} from '../components';
import { User } from './types'; import { User } from './types';
@@ -11,33 +21,67 @@ interface UserFormProps {
creating: boolean; creating: boolean;
user: User; user: User;
uniqueUsername: (value: any) => boolean; uniqueUsername: (value: any) => boolean;
handleValueChange: (name: keyof User) => (event: React.ChangeEvent<HTMLInputElement>) => void; handleValueChange: (
name: keyof User
) => (event: React.ChangeEvent<HTMLInputElement>) => void;
onDoneEditing: () => void; onDoneEditing: () => void;
onCancelEditing: () => void; onCancelEditing: () => void;
} }
class UserForm extends React.Component<UserFormProps> { class UserForm extends React.Component<UserFormProps> {
formRef: RefObject<any> = React.createRef(); formRef: RefObject<any> = React.createRef();
componentDidMount() { componentDidMount() {
ValidatorForm.addValidationRule('uniqueUsername', this.props.uniqueUsername); ValidatorForm.addValidationRule(
'uniqueUsername',
this.props.uniqueUsername
);
} }
submit = () => { submit = () => {
this.formRef.current.submit(); this.formRef.current.submit();
} };
render() { render() {
const { user, creating, handleValueChange, onDoneEditing, onCancelEditing } = this.props; const {
user,
creating,
handleValueChange,
onDoneEditing,
onCancelEditing
} = this.props;
return ( return (
<ValidatorForm onSubmit={onDoneEditing} ref={this.formRef}> <ValidatorForm onSubmit={onDoneEditing} ref={this.formRef}>
<Dialog onClose={onCancelEditing} aria-labelledby="user-form-dialog-title" open fullWidth maxWidth="sm"> <Dialog
<DialogTitle id="user-form-dialog-title">{creating ? 'Add' : 'Modify'} User</DialogTitle> onClose={onCancelEditing}
aria-labelledby="user-form-dialog-title"
open
fullWidth
maxWidth="sm"
>
<DialogTitle id="user-form-dialog-title">
{creating ? 'Add' : 'Modify'} User
</DialogTitle>
<DialogContent dividers> <DialogContent dividers>
<TextValidator <TextValidator
validators={creating ? ['required', 'uniqueUsername', 'matchRegexp:^[a-zA-Z0-9_\\.]{1,24}$'] : []} validators={
errorMessages={creating ? ['Username is required', "Username already exists", "Must be 1-24 characters: alpha numeric, '_' or '.'"] : []} 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" name="username"
label="Username" label="Username"
fullWidth fullWidth
@@ -49,7 +93,10 @@ class UserForm extends React.Component<UserFormProps> {
/> />
<PasswordValidator <PasswordValidator
validators={['required', 'matchRegexp:^.{1,64}$']} validators={['required', 'matchRegexp:^.{1,64}$']}
errorMessages={['Password is required', 'Password must be 64 characters or less']} errorMessages={[
'Password is required',
'Password must be 64 characters or less'
]}
name="password" name="password"
label="Password" label="Password"
fullWidth fullWidth
@@ -70,10 +117,19 @@ class UserForm extends React.Component<UserFormProps> {
/> />
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<FormButton variant="contained" color="secondary" onClick={onCancelEditing}> <FormButton
variant="contained"
color="secondary"
onClick={onCancelEditing}
>
Cancel Cancel
</FormButton> </FormButton>
<FormButton variant="contained" color="primary" type="submit" onClick={this.submit}> <FormButton
variant="contained"
color="primary"
type="submit"
onClick={this.submit}
>
Done Done
</FormButton> </FormButton>
</DialogActions> </DialogActions>

View File

@@ -1,14 +1,14 @@
export interface User { export interface User {
username: string username: string;
password: string password: string;
admin: boolean admin: boolean;
} }
export interface SecuritySettings { export interface SecuritySettings {
users: User[] users: User[];
jwt_secret: string jwt_secret: string;
} }
export interface GeneratedToken { export interface GeneratedToken {
token: string token: string;
} }

View File

@@ -16,46 +16,46 @@ const isLocalhost = Boolean(
window.location.hostname === '[::1]' || window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4. // 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match( window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/, /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
), )
) );
type Config = { type Config = {
onSuccess?: (registration: ServiceWorkerRegistration) => void onSuccess?: (registration: ServiceWorkerRegistration) => void;
onUpdate?: (registration: ServiceWorkerRegistration) => void onUpdate?: (registration: ServiceWorkerRegistration) => void;
} };
export function register(config?: Config) { export function register(config?: Config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW. // The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href) const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) { if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different 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 // 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 // serve assets; see https://github.com/facebook/create-react-app/issues/2374
return return;
} }
window.addEventListener('load', () => { window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js` const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) { if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not. // This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config) checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the // Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation. // service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => { navigator.serviceWorker.ready.then(() => {
console.log( console.log(
'This web app is being served cache-first by a service ' + 'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA', 'worker. To learn more, visit https://bit.ly/CRA-PWA'
) );
}) });
} else { } else {
// Is not localhost. Just register service worker // Is not localhost. Just register service worker
registerValidSW(swUrl, config) registerValidSW(swUrl, config);
} }
}) });
} }
} }
@@ -64,9 +64,9 @@ function registerValidSW(swUrl: string, config?: Config) {
.register(swUrl) .register(swUrl)
.then((registration) => { .then((registration) => {
registration.onupdatefound = () => { registration.onupdatefound = () => {
const installingWorker = registration.installing const installingWorker = registration.installing;
if (installingWorker == null) { if (installingWorker == null) {
return return;
} }
installingWorker.onstatechange = () => { installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') { if (installingWorker.state === 'installed') {
@@ -76,41 +76,41 @@ function registerValidSW(swUrl: string, config?: Config) {
// content until all client tabs are closed. // content until all client tabs are closed.
console.log( console.log(
'New content is available and will be used when all ' + 'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.', 'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
) );
// Execute callback // Execute callback
if (config && config.onUpdate) { if (config && config.onUpdate) {
config.onUpdate(registration) config.onUpdate(registration);
} }
} else { } else {
// At this point, everything has been precached. // At this point, everything has been precached.
// It's the perfect time to display a // It's the perfect time to display a
// "Content is cached for offline use." message. // "Content is cached for offline use." message.
console.log('Content is cached for offline use.') console.log('Content is cached for offline use.');
// Execute callback // Execute callback
if (config && config.onSuccess) { if (config && config.onSuccess) {
config.onSuccess(registration) config.onSuccess(registration);
} }
} }
} }
} };
} };
}) })
.catch((error) => { .catch((error) => {
console.error('Error during service worker registration:', error) console.error('Error during service worker registration:', error);
}) });
} }
function checkValidServiceWorker(swUrl: string, config?: Config) { function checkValidServiceWorker(swUrl: string, config?: Config) {
// Check if the service worker can be found. If it can't reload the page. // Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, { fetch(swUrl, {
headers: { 'Service-Worker': 'script' }, headers: { 'Service-Worker': 'script' }
}) })
.then((response) => { .then((response) => {
// Ensure service worker exists, and that we really are getting a JS file. // Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type') const contentType = response.headers.get('content-type');
if ( if (
response.status === 404 || response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1) (contentType != null && contentType.indexOf('javascript') === -1)
@@ -118,25 +118,25 @@ function checkValidServiceWorker(swUrl: string, config?: Config) {
// No service worker found. Probably a different app. Reload the page. // No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then((registration) => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister().then(() => { registration.unregister().then(() => {
window.location.reload() window.location.reload();
}) });
}) });
} else { } else {
// Service worker found. Proceed as normal. // Service worker found. Proceed as normal.
registerValidSW(swUrl, config) registerValidSW(swUrl, config);
} }
}) })
.catch(() => { .catch(() => {
console.log( console.log(
'No internet connection found. App is running in offline mode.', 'No internet connection found. App is running in offline mode.'
) );
}) });
} }
export function unregister() { export function unregister() {
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready.then((registration) => { navigator.serviceWorker.ready.then((registration) => {
registration.unregister() registration.unregister();
}) });
} }
} }

View File

@@ -1,4 +1,4 @@
const { createProxyMiddleware } = require('http-proxy-middleware') const { createProxyMiddleware } = require('http-proxy-middleware');
module.exports = function (app) { module.exports = function (app) {
app.use( app.use(
@@ -6,7 +6,7 @@ module.exports = function (app) {
createProxyMiddleware({ createProxyMiddleware({
target: 'http://localhost:3080', target: 'http://localhost:3080',
secure: false, secure: false,
changeOrigin: true, changeOrigin: true
}), })
) );
} };

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { OTA_SETTINGS_ENDPOINT } from '../api'; import { OTA_SETTINGS_ENDPOINT } from '../api';
import OTASettingsForm from './OTASettingsForm'; import OTASettingsForm from './OTASettingsForm';
@@ -9,7 +14,6 @@ import { OTASettings } from './types';
type OTASettingsControllerProps = RestControllerProps<OTASettings>; type OTASettingsControllerProps = RestControllerProps<OTASettings>;
class OTASettingsController extends Component<OTASettingsControllerProps> { class OTASettingsController extends Component<OTASettingsControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -19,12 +23,11 @@ class OTASettingsController extends Component<OTASettingsControllerProps> {
<SectionContent title="OTA Settings" titleGutter> <SectionContent title="OTA Settings" titleGutter>
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <OTASettingsForm {...formProps} />} render={(formProps) => <OTASettingsForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
); );
} }
} }
export default restController(OTA_SETTINGS_ENDPOINT, OTASettingsController); export default restController(OTA_SETTINGS_ENDPOINT, OTASettingsController);

View File

@@ -4,7 +4,13 @@ import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
import { Checkbox } from '@material-ui/core'; import { Checkbox } from '@material-ui/core';
import SaveIcon from '@material-ui/icons/Save'; import SaveIcon from '@material-ui/icons/Save';
import { RestFormProps, BlockFormControlLabel, PasswordValidator, FormButton, FormActions } from '../components'; import {
RestFormProps,
BlockFormControlLabel,
PasswordValidator,
FormButton,
FormActions
} from '../components';
import { isIP, isHostname, or } from '../validators'; import { isIP, isHostname, or } from '../validators';
import { OTASettings } from './types'; import { OTASettings } from './types';
@@ -12,7 +18,6 @@ import { OTASettings } from './types';
type OTASettingsFormProps = RestFormProps<OTASettings>; type OTASettingsFormProps = RestFormProps<OTASettings>;
class OTASettingsForm extends React.Component<OTASettingsFormProps> { class OTASettingsForm extends React.Component<OTASettingsFormProps> {
componentDidMount() { componentDidMount() {
ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname)); ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
} }
@@ -25,14 +30,24 @@ class OTASettingsForm extends React.Component<OTASettingsFormProps> {
control={ control={
<Checkbox <Checkbox
checked={data.enabled} checked={data.enabled}
onChange={handleValueChange("enabled")} onChange={handleValueChange('enabled')}
/> />
} }
label="Enable OTA Updates" label="Enable OTA Updates"
/> />
<TextValidator <TextValidator
validators={['required', 'isNumber', 'minNumber:1025', 'maxNumber:65535']} validators={[
errorMessages={['Port is required', "Must be a number", "Must be greater than 1024 ", "Max value is 65535"]} '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" name="port"
label="Port" label="Port"
fullWidth fullWidth
@@ -44,7 +59,10 @@ class OTASettingsForm extends React.Component<OTASettingsFormProps> {
/> />
<PasswordValidator <PasswordValidator
validators={['required', 'matchRegexp:^.{1,64}$']} validators={['required', 'matchRegexp:^.{1,64}$']}
errorMessages={['OTA Password is required', 'OTA Point Password must be 64 characters or less']} errorMessages={[
'OTA Password is required',
'OTA Point Password must be 64 characters or less'
]}
name="password" name="password"
label="Password" label="Password"
fullWidth fullWidth
@@ -54,7 +72,12 @@ class OTASettingsForm extends React.Component<OTASettingsFormProps> {
margin="normal" margin="normal"
/> />
<FormActions> <FormActions>
<FormButton startIcon={<SaveIcon />} variant="contained" color="primary" type="submit"> <FormButton
startIcon={<SaveIcon />}
variant="contained"
color="primary"
type="submit"
>
Save Save
</FormButton> </FormButton>
</FormActions> </FormActions>

View File

@@ -1,22 +1,27 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { Redirect, Switch, RouteComponentProps } from 'react-router-dom' import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
import { Tabs, Tab } from '@material-ui/core'; import { Tabs, Tab } from '@material-ui/core';
import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext'; import { WithFeaturesProps, withFeatures } from '../features/FeaturesContext';
import { withAuthenticatedContext, AuthenticatedContextProps, AuthenticatedRoute } from '../authentication'; import {
withAuthenticatedContext,
AuthenticatedContextProps,
AuthenticatedRoute
} from '../authentication';
import { MenuAppBar } from '../components'; import { MenuAppBar } from '../components';
import SystemStatusController from './SystemStatusController'; import SystemStatusController from './SystemStatusController';
import OTASettingsController from './OTASettingsController'; import OTASettingsController from './OTASettingsController';
import UploadFirmwareController from './UploadFirmwareController'; import UploadFirmwareController from './UploadFirmwareController';
type SystemProps = AuthenticatedContextProps & RouteComponentProps & WithFeaturesProps; type SystemProps = AuthenticatedContextProps &
RouteComponentProps &
WithFeaturesProps;
class System extends Component<SystemProps> { class System extends Component<SystemProps> {
handleTabChange = (path: string) => {
handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
this.props.history.push(path); this.props.history.push(path);
}; };
@@ -24,27 +29,51 @@ class System extends Component<SystemProps> {
const { authenticatedContext, features } = this.props; const { authenticatedContext, features } = this.props;
return ( return (
<MenuAppBar sectionTitle="System"> <MenuAppBar sectionTitle="System">
<Tabs value={this.props.match.url} onChange={this.handleTabChange} variant="fullWidth"> <Tabs
value={this.props.match.url}
onChange={(e, path) => this.handleTabChange(path)}
variant="fullWidth"
>
<Tab value="/system/status" label="System Status" /> <Tab value="/system/status" label="System Status" />
{features.ota && ( {features.ota && (
<Tab value="/system/ota" label="OTA Settings" disabled={!authenticatedContext.me.admin} /> <Tab
value="/system/ota"
label="OTA Settings"
disabled={!authenticatedContext.me.admin}
/>
)} )}
{features.upload_firmware && ( {features.upload_firmware && (
<Tab value="/system/upload" label="Upload Firmware" disabled={!authenticatedContext.me.admin} /> <Tab
value="/system/upload"
label="Upload Firmware"
disabled={!authenticatedContext.me.admin}
/>
)} )}
</Tabs> </Tabs>
<Switch> <Switch>
<AuthenticatedRoute exact path="/system/status" component={SystemStatusController} /> <AuthenticatedRoute
exact
path="/system/status"
component={SystemStatusController}
/>
{features.ota && ( {features.ota && (
<AuthenticatedRoute exact path="/system/ota" component={OTASettingsController} /> <AuthenticatedRoute
exact
path="/system/ota"
component={OTASettingsController}
/>
)} )}
{features.upload_firmware && ( {features.upload_firmware && (
<AuthenticatedRoute exact path="/system/upload" component={UploadFirmwareController} /> <AuthenticatedRoute
exact
path="/system/upload"
component={UploadFirmwareController}
/>
)} )}
<Redirect to="/system/status" /> <Redirect to="/system/status" />
</Switch> </Switch>
</MenuAppBar> </MenuAppBar>
) );
} }
} }

View File

@@ -1,6 +1,11 @@
import React, { Component } from 'react'; import React, { Component } from 'react';
import { restController, RestControllerProps, RestFormLoader, SectionContent } from '../components'; import {
restController,
RestControllerProps,
RestFormLoader,
SectionContent
} from '../components';
import { SYSTEM_STATUS_ENDPOINT } from '../api'; import { SYSTEM_STATUS_ENDPOINT } from '../api';
import SystemStatusForm from './SystemStatusForm'; import SystemStatusForm from './SystemStatusForm';
@@ -9,7 +14,6 @@ import { SystemStatus } from './types';
type SystemStatusControllerProps = RestControllerProps<SystemStatus>; type SystemStatusControllerProps = RestControllerProps<SystemStatus>;
class SystemStatusController extends Component<SystemStatusControllerProps> { class SystemStatusController extends Component<SystemStatusControllerProps> {
componentDidMount() { componentDidMount() {
this.props.loadData(); this.props.loadData();
} }
@@ -19,12 +23,11 @@ class SystemStatusController extends Component<SystemStatusControllerProps> {
<SectionContent title="System Status"> <SectionContent title="System Status">
<RestFormLoader <RestFormLoader
{...this.props} {...this.props}
render={formProps => <SystemStatusForm {...formProps} />} render={(formProps) => <SystemStatusForm {...formProps} />}
/> />
</SectionContent> </SectionContent>
); );
} }
} }
export default restController(SYSTEM_STATUS_ENDPOINT, SystemStatusController); export default restController(SYSTEM_STATUS_ENDPOINT, SystemStatusController);

View File

@@ -1,4 +1,4 @@
import React, { Component } from 'react'; import { Component } from 'react';
import { SectionContent } from '../components'; import { SectionContent } from '../components';
import { UPLOAD_FIRMWARE_ENDPOINT } from '../api'; import { UPLOAD_FIRMWARE_ENDPOINT } from '../api';
@@ -12,8 +12,10 @@ interface UploadFirmwareControllerState {
progress?: ProgressEvent; progress?: ProgressEvent;
} }
class UploadFirmwareController extends Component<WithSnackbarProps, UploadFirmwareControllerState> { class UploadFirmwareController extends Component<
WithSnackbarProps,
UploadFirmwareControllerState
> {
state: UploadFirmwareControllerState = { state: UploadFirmwareControllerState = {
xhr: undefined, xhr: undefined,
progress: undefined progress: undefined
@@ -25,47 +27,67 @@ class UploadFirmwareController extends Component<WithSnackbarProps, UploadFirmwa
updateProgress = (progress: ProgressEvent) => { updateProgress = (progress: ProgressEvent) => {
this.setState({ progress }); this.setState({ progress });
} };
uploadFile = (file: File) => { uploadFile = (file: File) => {
if (this.state.xhr) { if (this.state.xhr) {
return; return;
} }
var xhr = new XMLHttpRequest(); const xhr = new XMLHttpRequest();
this.setState({ xhr }); this.setState({ xhr });
redirectingAuthorizedUpload(xhr, UPLOAD_FIRMWARE_ENDPOINT, file, this.updateProgress).then(() => { redirectingAuthorizedUpload(
if (xhr.status !== 200) { xhr,
throw Error("Invalid status code: " + xhr.status); UPLOAD_FIRMWARE_ENDPOINT,
} file,
this.props.enqueueSnackbar("Activating new firmware", { variant: 'success' }); this.updateProgress
this.setState({ xhr: undefined, progress: undefined }); )
}).catch((error: Error) => { .then(() => {
if (error.name === 'AbortError') { if (xhr.status !== 200) {
this.props.enqueueSnackbar("Upload cancelled by user", { variant: 'warning' }); throw Error('Invalid status code: ' + xhr.status);
} else { }
const errorMessage = error.name === 'UploadError' ? "Error during upload" : (error.message || "Unknown error"); this.props.enqueueSnackbar('Activating new firmware', {
this.props.enqueueSnackbar("Problem uploading: " + errorMessage, { variant: 'error' }); variant: 'success'
});
this.setState({ xhr: undefined, progress: undefined }); 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 = () => { cancelUpload = () => {
if (this.state.xhr) { if (this.state.xhr) {
this.state.xhr.abort(); this.state.xhr.abort();
this.setState({ xhr: undefined, progress: undefined }); this.setState({ xhr: undefined, progress: undefined });
} }
} };
render() { render() {
const { xhr, progress } = this.state; const { xhr, progress } = this.state;
return ( return (
<SectionContent title="Upload Firmware"> <SectionContent title="Upload Firmware">
<UploadFirmwareForm onFileSelected={this.uploadFile} onCancel={this.cancelUpload} uploading={!!xhr} progress={progress} /> <UploadFirmwareForm
onFileSelected={this.uploadFile}
onCancel={this.cancelUpload}
uploading={!!xhr}
progress={progress}
/>
</SectionContent> </SectionContent>
); );
} }
} }
export default withSnackbar(UploadFirmwareController); export default withSnackbar(UploadFirmwareController);

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