>(
- endpointUrl: string,
- RestController: React.ComponentType>
-) {
- return withSnackbar(
- class extends React.Component<
- Omit
> & WithSnackbarProps,
- RestControllerState
- > {
- state: RestControllerState = {
- data: undefined,
- loading: false,
- errorMessage: undefined
- };
-
- setData = (data: D, callback?: () => void) => {
- this.setState(
- {
- data,
- loading: false,
- errorMessage: undefined
- },
- callback
- );
- };
-
- loadData = () => {
- this.setState({
- data: undefined,
- loading: true,
- errorMessage: undefined
- });
- redirectingAuthorizedFetch(endpointUrl)
- .then((response) => {
- if (response.status === 200) {
- return response.json();
- }
- throw Error('Invalid status code: ' + response.status);
- })
- .then((json) => {
- this.setState({ data: json, loading: false });
- })
- .catch((error) => {
- const errorMessage = error.message || 'Unknown error';
- this.props.enqueueSnackbar('Problem fetching: ' + errorMessage, {
- variant: 'error'
- });
- this.setState({ data: undefined, loading: false, errorMessage });
- });
- };
-
- saveData = () => {
- this.setState({ loading: true });
- redirectingAuthorizedFetch(endpointUrl, {
- method: 'POST',
- body: JSON.stringify(this.state.data),
- headers: {
- 'Content-Type': 'application/json'
- }
- })
- .then((response) => {
- if (response.status === 200) {
- return response.json();
- }
- throw Error('Invalid status code: ' + response.status);
- })
- .then((json) => {
- this.props.enqueueSnackbar('Update successful.', {
- variant: 'success'
- });
- this.setState({ data: json, loading: false });
- })
- .catch((error) => {
- const errorMessage = error.message || 'Unknown error';
- this.props.enqueueSnackbar('Problem updating: ' + errorMessage, {
- variant: 'error'
- });
- this.setState({ data: undefined, loading: false, errorMessage });
- });
- };
-
- handleValueChange = (name: keyof D) => (
- event: React.ChangeEvent
- ) => {
- const data = { ...this.state.data!, [name]: extractEventValue(event) };
- this.setState({ data });
- };
-
- render() {
- return (
-
- );
- }
- }
- );
-}
diff --git a/interface/src/components/RestFormLoader.tsx b/interface/src/components/RestFormLoader.tsx
deleted file mode 100644
index ca65805fd..000000000
--- a/interface/src/components/RestFormLoader.tsx
+++ /dev/null
@@ -1,64 +0,0 @@
-import React from 'react';
-
-import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
-import { Button, LinearProgress, Typography } from '@material-ui/core';
-
-import { RestControllerProps } from '.';
-
-const useStyles = makeStyles((theme: Theme) =>
- createStyles({
- loadingSettings: {
- margin: theme.spacing(0.5)
- },
- loadingSettingsDetails: {
- margin: theme.spacing(4),
- textAlign: 'center'
- },
- button: {
- marginRight: theme.spacing(2),
- marginTop: theme.spacing(2)
- }
- })
-);
-
-export type RestFormProps = Omit<
- RestControllerProps,
- 'loading' | 'errorMessage'
-> & { data: D };
-
-interface RestFormLoaderProps extends RestControllerProps {
- render: (props: RestFormProps) => JSX.Element;
-}
-
-export default function RestFormLoader(props: RestFormLoaderProps) {
- const { loading, errorMessage, loadData, render, data, ...rest } = props;
- const classes = useStyles();
- if (loading || !data) {
- return (
-
-
-
- Loading…
-
-
- );
- }
- if (errorMessage) {
- return (
-
-
- {errorMessage}
-
-
-
- );
- }
- return render({ ...rest, loadData, data });
-}
diff --git a/interface/src/components/SectionContent.tsx b/interface/src/components/SectionContent.tsx
index fdd0ede8a..de205a7f8 100644
--- a/interface/src/components/SectionContent.tsx
+++ b/interface/src/components/SectionContent.tsx
@@ -1,31 +1,20 @@
-import React from 'react';
+import { FC } from 'react';
-import { Typography, Paper } from '@material-ui/core';
-import { createStyles, Theme, makeStyles } from '@material-ui/core/styles';
+import { Paper, Divider } from '@mui/material';
-const useStyles = makeStyles((theme: Theme) =>
- createStyles({
- content: {
- padding: theme.spacing(2),
- margin: theme.spacing(3)
- }
- })
-);
+import { RequiredChildrenProps } from '../utils';
-interface SectionContentProps {
+interface SectionContentProps extends RequiredChildrenProps {
title: string;
titleGutter?: boolean;
id?: string;
}
-const SectionContent: React.FC = (props) => {
- const { children, title, titleGutter, id } = props;
- const classes = useStyles();
+const SectionContent: FC = (props) => {
+ const { children, title, id } = props;
return (
-
-
- {title}
-
+
+ {title}
{children}
);
diff --git a/interface/src/components/SingleUpload.tsx b/interface/src/components/SingleUpload.tsx
deleted file mode 100644
index fe422354a..000000000
--- a/interface/src/components/SingleUpload.tsx
+++ /dev/null
@@ -1,128 +0,0 @@
-import React, { FC, Fragment } from 'react';
-import { useDropzone, DropzoneState } from 'react-dropzone';
-
-import { makeStyles, createStyles } from '@material-ui/styles';
-import CloudUploadIcon from '@material-ui/icons/CloudUpload';
-import CancelIcon from '@material-ui/icons/Cancel';
-import {
- Theme,
- Box,
- Typography,
- LinearProgress,
- Button
-} from '@material-ui/core';
-
-interface SingleUploadStyleProps extends DropzoneState {
- uploading: boolean;
-}
-
-const progressPercentage = (progress: ProgressEvent) =>
- Math.round((progress.loaded * 100) / progress.total);
-
-const getBorderColor = (theme: Theme, props: SingleUploadStyleProps) => {
- if (props.isDragAccept) {
- return theme.palette.success.main;
- }
- if (props.isDragReject) {
- return theme.palette.error.main;
- }
- if (props.isDragActive) {
- return theme.palette.info.main;
- }
- return theme.palette.grey[700];
-};
-
-const useStyles = makeStyles((theme: Theme) =>
- createStyles({
- dropzone: {
- padding: theme.spacing(8, 2),
- borderWidth: 2,
- borderRadius: 2,
- borderStyle: 'dashed',
- color: theme.palette.grey[700],
- transition: 'border .24s ease-in-out',
- cursor: (props: SingleUploadStyleProps) =>
- props.uploading ? 'default' : 'pointer',
- width: '100%',
- borderColor: (props: SingleUploadStyleProps) =>
- getBorderColor(theme, props)
- }
- })
-);
-
-export interface SingleUploadProps {
- onDrop: (acceptedFiles: File[]) => void;
- onCancel: () => void;
- accept?: string | string[];
- uploading: boolean;
- progress?: ProgressEvent;
-}
-
-const SingleUpload: FC = ({
- onDrop,
- onCancel,
- accept,
- uploading,
- progress
-}) => {
- const dropzoneState = useDropzone({
- onDrop,
- accept,
- disabled: uploading,
- multiple: false
- });
- const { getRootProps, getInputProps } = dropzoneState;
- const classes = useStyles({ ...dropzoneState, uploading });
-
- const renderProgressText = () => {
- if (uploading) {
- if (progress?.lengthComputable) {
- return `Uploading: ${progressPercentage(progress)}%`;
- }
- return 'Uploading\u2026';
- }
- return 'Drop file or click here';
- };
-
- const renderProgress = (progress?: ProgressEvent) => (
-
- );
-
- return (
-
-
-
-
- {renderProgressText()}
- {uploading && (
-
-
- {renderProgress(progress)}
-
- }
- variant="contained"
- color="secondary"
- onClick={onCancel}
- >
- Cancel
-
-
- )}
-
-
- );
-};
-
-export default SingleUpload;
diff --git a/interface/src/components/WebSocketController.tsx b/interface/src/components/WebSocketController.tsx
deleted file mode 100644
index fda9bf591..000000000
--- a/interface/src/components/WebSocketController.tsx
+++ /dev/null
@@ -1,158 +0,0 @@
-import React from 'react';
-import Sockette from 'sockette';
-import throttle from 'lodash/throttle';
-import { withSnackbar, WithSnackbarProps } from 'notistack';
-
-import { addAccessTokenParameter } from '../authentication';
-import { extractEventValue } from '.';
-
-export interface WebSocketControllerProps extends WithSnackbarProps {
- handleValueChange: (
- name: keyof D
- ) => (event: React.ChangeEvent) => void;
-
- setData: (data: D, callback?: () => void) => void;
- saveData: () => void;
- saveDataAndClear(): () => void;
-
- connected: boolean;
- data?: D;
-}
-
-interface WebSocketControllerState {
- ws: Sockette;
- connected: boolean;
- clientId?: string;
- data?: D;
-}
-
-enum WebSocketMessageType {
- ID = 'id',
- PAYLOAD = 'payload'
-}
-
-interface WebSocketIdMessage {
- type: typeof WebSocketMessageType.ID;
- id: string;
-}
-
-interface WebSocketPayloadMessage {
- type: typeof WebSocketMessageType.PAYLOAD;
- origin_id: string;
- payload: D;
-}
-
-export type WebSocketMessage =
- | WebSocketIdMessage
- | WebSocketPayloadMessage;
-
-export function webSocketController>(
- wsUrl: string,
- wsThrottle: number,
- WebSocketController: React.ComponentType>
-) {
- return withSnackbar(
- class extends React.Component<
- Omit
> & WithSnackbarProps,
- WebSocketControllerState
- > {
- constructor(
- props: Omit> & WithSnackbarProps
- ) {
- super(props);
- this.state = {
- ws: new Sockette(addAccessTokenParameter(wsUrl), {
- onmessage: this.onMessage,
- onopen: this.onOpen,
- onclose: this.onClose
- }),
- connected: false
- };
- }
-
- componentWillUnmount() {
- this.state.ws.close();
- }
-
- onMessage = (event: MessageEvent) => {
- const rawData = event.data;
- if (typeof rawData === 'string' || rawData instanceof String) {
- this.handleMessage(
- JSON.parse(rawData as string) as WebSocketMessage
- );
- }
- };
-
- handleMessage = (message: WebSocketMessage) => {
- const { clientId, data } = this.state;
-
- switch (message.type) {
- case WebSocketMessageType.ID:
- this.setState({ clientId: message.id });
- break;
- case WebSocketMessageType.PAYLOAD:
- if (clientId && (!data || clientId !== message.origin_id)) {
- this.setState({ data: message.payload });
- }
- break;
- }
- };
-
- onOpen = () => {
- this.setState({ connected: true });
- };
-
- onClose = () => {
- this.setState({
- connected: false,
- clientId: undefined,
- data: undefined
- });
- };
-
- setData = (data: D, callback?: () => void) => {
- this.setState({ data }, callback);
- };
-
- saveData = throttle(() => {
- const { ws, connected, data } = this.state;
- if (connected) {
- ws.json(data);
- }
- }, wsThrottle);
-
- saveDataAndClear = throttle(() => {
- const { ws, connected, data } = this.state;
- if (connected) {
- this.setState(
- {
- data: undefined
- },
- () => ws.json(data)
- );
- }
- }, wsThrottle);
-
- handleValueChange = (name: keyof D) => (
- event: React.ChangeEvent
- ) => {
- const data = { ...this.state.data!, [name]: extractEventValue(event) };
- this.setState({ data });
- };
-
- render() {
- return (
-
- );
- }
- }
- );
-}
diff --git a/interface/src/components/WebSocketFormLoader.tsx b/interface/src/components/WebSocketFormLoader.tsx
deleted file mode 100644
index eb4c2e582..000000000
--- a/interface/src/components/WebSocketFormLoader.tsx
+++ /dev/null
@@ -1,43 +0,0 @@
-import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
-import { LinearProgress, Typography } from '@material-ui/core';
-
-import { WebSocketControllerProps } from '.';
-
-const useStyles = makeStyles((theme: Theme) =>
- createStyles({
- loadingSettings: {
- margin: theme.spacing(0.5)
- },
- loadingSettingsDetails: {
- margin: theme.spacing(4),
- textAlign: 'center'
- }
- })
-);
-
-export type WebSocketFormProps = Omit<
- WebSocketControllerProps,
- 'connected'
-> & { data: D };
-
-interface WebSocketFormLoaderProps extends WebSocketControllerProps {
- render: (props: WebSocketFormProps) => JSX.Element;
-}
-
-export default function WebSocketFormLoader(
- props: WebSocketFormLoaderProps
-) {
- const { connected, render, data, ...rest } = props;
- const classes = useStyles();
- if (!connected || !data) {
- return (
-
-
-
- Connecting to WebSocket...
-
-
- );
- }
- return render({ ...rest, data });
-}
diff --git a/interface/src/components/WindowSize.tsx b/interface/src/components/WindowSize.tsx
deleted file mode 100644
index d69405ae8..000000000
--- a/interface/src/components/WindowSize.tsx
+++ /dev/null
@@ -1,14 +0,0 @@
-import { useLayoutEffect, useState } from 'react';
-
-export function useWindowSize() {
- const [size, setSize] = useState([0, 0]);
- useLayoutEffect(() => {
- function updateSize() {
- setSize([window.innerWidth, window.innerHeight]);
- }
- window.addEventListener('resize', updateSize);
- updateSize();
- return () => window.removeEventListener('resize', updateSize);
- }, []);
- return size;
-}
diff --git a/interface/src/components/index.ts b/interface/src/components/index.ts
index e98047039..5d5c89e6f 100644
--- a/interface/src/components/index.ts
+++ b/interface/src/components/index.ts
@@ -1,20 +1,8 @@
-export { default as BlockFormControlLabel } from './BlockFormControlLabel';
-export { default as FormActions } from './FormActions';
-export { default as FormButton } from './FormButton';
-export { default as HighlightAvatar } from './HighlightAvatar';
-export { default as MenuAppBar } from './MenuAppBar';
-export { default as PasswordValidator } from './PasswordValidator';
-export { default as RestFormLoader } from './RestFormLoader';
-export { default as FormLoader } from './FormLoader';
+export * from './inputs';
+export * from './layout';
+export * from './loading';
+export * from './routing';
+export * from './upload';
export { default as SectionContent } from './SectionContent';
-export { default as WebSocketFormLoader } from './WebSocketFormLoader';
-export { default as ErrorButton } from './ErrorButton';
-export { default as SingleUpload } from './SingleUpload';
-
-export * from './RestFormLoader';
-export * from './RestController';
-
-export * from './WebSocketFormLoader';
-export * from './WebSocketController';
-
-export * from './WindowSize';
+export { default as ButtonRow } from './ButtonRow';
+export { default as MessageBox } from './MessageBox';
diff --git a/interface/src/components/BlockFormControlLabel.tsx b/interface/src/components/inputs/BlockFormControlLabel.tsx
similarity index 71%
rename from interface/src/components/BlockFormControlLabel.tsx
rename to interface/src/components/inputs/BlockFormControlLabel.tsx
index 3067c961f..e3e8b7cd3 100644
--- a/interface/src/components/BlockFormControlLabel.tsx
+++ b/interface/src/components/inputs/BlockFormControlLabel.tsx
@@ -1,5 +1,5 @@
import { FC } from 'react';
-import { FormControlLabel, FormControlLabelProps } from '@material-ui/core';
+import { FormControlLabel, FormControlLabelProps } from '@mui/material';
const BlockFormControlLabel: FC = (props) => (
diff --git a/interface/src/components/inputs/ValidatedPasswordField.tsx b/interface/src/components/inputs/ValidatedPasswordField.tsx
new file mode 100644
index 000000000..d4a215199
--- /dev/null
+++ b/interface/src/components/inputs/ValidatedPasswordField.tsx
@@ -0,0 +1,36 @@
+import { FC, useState } from 'react';
+
+import { IconButton, InputAdornment } from '@mui/material';
+import VisibilityIcon from '@mui/icons-material/Visibility';
+import VisibilityOffIcon from '@mui/icons-material/VisibilityOff';
+
+import ValidatedTextField, { ValidatedTextFieldProps } from './ValidatedTextField';
+
+type ValidatedPasswordFieldProps = Omit
;
+
+const ValidatedPasswordField: FC = ({ InputProps, ...props }) => {
+ const [showPassword, setShowPassword] = useState(false);
+
+ return (
+
+ setShowPassword(!showPassword)}
+ edge="end"
+ >
+ {showPassword ? : }
+
+
+ )
+ }}
+ />
+ );
+};
+
+export default ValidatedPasswordField;
diff --git a/interface/src/components/inputs/ValidatedTextField.tsx b/interface/src/components/inputs/ValidatedTextField.tsx
new file mode 100644
index 000000000..8d07f8127
--- /dev/null
+++ b/interface/src/components/inputs/ValidatedTextField.tsx
@@ -0,0 +1,24 @@
+import { FC } from 'react';
+import { ValidateFieldsError } from 'async-validator';
+
+import { FormHelperText, TextField, TextFieldProps } from '@mui/material';
+
+interface ValidatedFieldProps {
+ fieldErrors?: ValidateFieldsError;
+ name: string;
+}
+
+export type ValidatedTextFieldProps = ValidatedFieldProps & TextFieldProps;
+
+const ValidatedTextField: FC = ({ fieldErrors, ...rest }) => {
+ const errors = fieldErrors && fieldErrors[rest.name];
+ const renderErrors = () => errors && errors.map((e, i) => {e.message});
+ return (
+ <>
+
+ {renderErrors()}
+ >
+ );
+};
+
+export default ValidatedTextField;
diff --git a/interface/src/components/inputs/index.ts b/interface/src/components/inputs/index.ts
new file mode 100644
index 000000000..daae8a727
--- /dev/null
+++ b/interface/src/components/inputs/index.ts
@@ -0,0 +1,3 @@
+export { default as BlockFormControlLabel } from './BlockFormControlLabel';
+export { default as ValidatedPasswordField } from './ValidatedPasswordField';
+export { default as ValidatedTextField } from './ValidatedTextField';
diff --git a/interface/src/components/layout/Layout.tsx b/interface/src/components/layout/Layout.tsx
new file mode 100644
index 000000000..c4f2c8e31
--- /dev/null
+++ b/interface/src/components/layout/Layout.tsx
@@ -0,0 +1,38 @@
+import { FC, useState, useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+
+import { Box, Toolbar } from '@mui/material';
+
+import { PROJECT_NAME } from '../../api/env';
+import { RequiredChildrenProps } from '../../utils';
+
+import LayoutDrawer from './LayoutDrawer';
+import LayoutAppBar from './LayoutAppBar';
+import { LayoutContext } from './context';
+
+export const DRAWER_WIDTH = 240;
+
+const Layout: FC = ({ children }) => {
+ const [mobileOpen, setMobileOpen] = useState(false);
+ const [title, setTitle] = useState(PROJECT_NAME);
+ const { pathname } = useLocation();
+
+ const handleDrawerToggle = () => {
+ setMobileOpen(!mobileOpen);
+ };
+
+ useEffect(() => setMobileOpen(false), [pathname]);
+
+ return (
+
+
+
+
+
+ {children}
+
+
+ );
+};
+
+export default Layout;
diff --git a/interface/src/components/layout/LayoutAppBar.tsx b/interface/src/components/layout/LayoutAppBar.tsx
new file mode 100644
index 000000000..74e06fdfd
--- /dev/null
+++ b/interface/src/components/layout/LayoutAppBar.tsx
@@ -0,0 +1,50 @@
+import { FC, useContext } from 'react';
+
+import { AppBar, Box, IconButton, Toolbar, Typography } from '@mui/material';
+import MenuIcon from '@mui/icons-material/Menu';
+
+import LayoutAuthMenu from './LayoutAuthMenu';
+
+import { FeaturesContext } from '../../contexts/features';
+
+export const DRAWER_WIDTH = 240;
+
+interface LayoutAppBarProps {
+ title: string;
+ onToggleDrawer: () => void;
+}
+
+const LayoutAppBar: FC = ({ title, onToggleDrawer }) => {
+ const { features } = useContext(FeaturesContext);
+
+ return (
+
+
+
+
+
+
+ {title}
+
+
+ {features.security && }
+
+
+ );
+};
+
+export default LayoutAppBar;
diff --git a/interface/src/components/layout/LayoutAuthMenu.tsx b/interface/src/components/layout/LayoutAuthMenu.tsx
new file mode 100644
index 000000000..26c1d3fc4
--- /dev/null
+++ b/interface/src/components/layout/LayoutAuthMenu.tsx
@@ -0,0 +1,73 @@
+import { FC, useState, useContext } from 'react';
+
+import { Box, Button, Divider, IconButton, Popover, Typography, Avatar, styled, TypographyProps } from '@mui/material';
+
+import PersonIcon from '@mui/icons-material/Person';
+import AccountCircleIcon from '@mui/icons-material/AccountCircle';
+
+import { AuthenticatedContext } from '../../contexts/authentication';
+
+const ItemTypography = styled(Typography)({
+ maxWidth: '250px',
+ whiteSpace: 'nowrap',
+ overflow: 'hidden',
+ textOverflow: 'ellipsis'
+});
+
+const LayoutAuthMenu: FC = () => {
+ const { me, signOut } = useContext(AuthenticatedContext);
+
+ const [anchorEl, setAnchorEl] = useState(null);
+
+ const handleClick = (event: React.MouseEvent) => {
+ setAnchorEl(event.currentTarget);
+ };
+
+ const handleClose = () => {
+ setAnchorEl(null);
+ };
+
+ const open = Boolean(anchorEl);
+ const id = anchorEl ? 'app-menu-popover' : undefined;
+
+ return (
+ <>
+
+
+ >
+ );
+};
+
+export default LayoutAuthMenu;
diff --git a/interface/src/components/layout/LayoutDrawer.tsx b/interface/src/components/layout/LayoutDrawer.tsx
new file mode 100644
index 000000000..da61c62f4
--- /dev/null
+++ b/interface/src/components/layout/LayoutDrawer.tsx
@@ -0,0 +1,73 @@
+import { FC } from 'react';
+
+import { Box, Divider, Drawer, Toolbar, Typography, styled } from '@mui/material';
+
+import { PROJECT_NAME } from '../../api/env';
+
+import LayoutMenu from './LayoutMenu';
+import { DRAWER_WIDTH } from './Layout';
+
+const LayoutDrawerLogo = styled('img')(({ theme }) => ({
+ [theme.breakpoints.down('sm')]: {
+ height: 24,
+ marginRight: theme.spacing(2)
+ },
+ [theme.breakpoints.up('sm')]: {
+ height: 36,
+ marginRight: theme.spacing(2)
+ }
+}));
+
+interface LayoutDrawerProps {
+ mobileOpen: boolean;
+ onClose: () => void;
+}
+
+const LayoutDrawer: FC = ({ mobileOpen, onClose }) => {
+ const drawer = (
+ <>
+
+
+
+
+ {PROJECT_NAME}
+
+
+
+
+
+
+ >
+ );
+
+ return (
+
+
+ {drawer}
+
+
+ {drawer}
+
+
+ );
+};
+
+export default LayoutDrawer;
diff --git a/interface/src/components/layout/LayoutMenu.tsx b/interface/src/components/layout/LayoutMenu.tsx
new file mode 100644
index 000000000..f3e1bfb6d
--- /dev/null
+++ b/interface/src/components/layout/LayoutMenu.tsx
@@ -0,0 +1,42 @@
+import { FC, useContext } from 'react';
+
+import { Divider, List } from '@mui/material';
+
+import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
+import AccessTimeIcon from '@mui/icons-material/AccessTime';
+import DeviceHubIcon from '@mui/icons-material/DeviceHub';
+import SettingsIcon from '@mui/icons-material/Settings';
+import LockIcon from '@mui/icons-material/Lock';
+import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
+
+import { FeaturesContext } from '../../contexts/features';
+import ProjectMenu from '../../project/ProjectMenu';
+
+import LayoutMenuItem from './LayoutMenuItem';
+import { AuthenticatedContext } from '../../contexts/authentication';
+
+const LayoutMenu: FC = () => {
+ const { features } = useContext(FeaturesContext);
+ const authenticatedContext = useContext(AuthenticatedContext);
+
+ return (
+ <>
+ {features.project && (
+
+
+
+
+ )}
+
+
+
+ {features.ntp && }
+ {features.mqtt && }
+
+
+
+ >
+ );
+};
+
+export default LayoutMenu;
diff --git a/interface/src/components/layout/LayoutMenuItem.tsx b/interface/src/components/layout/LayoutMenuItem.tsx
new file mode 100644
index 000000000..2484acea7
--- /dev/null
+++ b/interface/src/components/layout/LayoutMenuItem.tsx
@@ -0,0 +1,32 @@
+import { FC } from 'react';
+import { Link, useLocation } from 'react-router-dom';
+
+import { ListItem, ListItemButton, ListItemIcon, ListItemText, SvgIconProps } from '@mui/material';
+
+import { grey } from '@mui/material/colors';
+
+import { routeMatches } from '../../utils';
+
+interface LayoutMenuItemProps {
+ icon: React.ComponentType;
+ label: string;
+ to: string;
+ disabled?: boolean;
+}
+
+const LayoutMenuItem: FC = ({ icon: Icon, label, to, disabled }) => {
+ const { pathname } = useLocation();
+
+ return (
+
+
+
+
+
+ {label}
+
+
+ );
+};
+
+export default LayoutMenuItem;
diff --git a/interface/src/components/layout/context.ts b/interface/src/components/layout/context.ts
new file mode 100644
index 000000000..ce1d81f43
--- /dev/null
+++ b/interface/src/components/layout/context.ts
@@ -0,0 +1,25 @@
+import { useRef, useEffect, createContext, useContext } from 'react';
+
+export interface LayoutContextValue {
+ title: string;
+ setTitle: (title: string) => void;
+}
+
+const LayoutContextDefaultValue = {} as LayoutContextValue;
+export const LayoutContext = createContext(LayoutContextDefaultValue);
+
+export const useLayoutTitle = (myTitle: string) => {
+ const { title, setTitle } = useContext(LayoutContext);
+ const previousTitle = useRef(title);
+
+ useEffect(() => {
+ setTitle(myTitle);
+ }, [setTitle, myTitle]);
+
+ useEffect(
+ () => () => {
+ setTitle(previousTitle.current);
+ },
+ [setTitle]
+ );
+};
diff --git a/interface/src/components/layout/index.ts b/interface/src/components/layout/index.ts
new file mode 100644
index 000000000..8fd8849af
--- /dev/null
+++ b/interface/src/components/layout/index.ts
@@ -0,0 +1,2 @@
+export * from './context';
+export { default as Layout } from './Layout';
diff --git a/interface/src/components/loading/ApplicationError.tsx b/interface/src/components/loading/ApplicationError.tsx
new file mode 100644
index 000000000..9ab428d58
--- /dev/null
+++ b/interface/src/components/loading/ApplicationError.tsx
@@ -0,0 +1,43 @@
+import { FC } from 'react';
+
+import { Box, Paper, Typography } from '@mui/material';
+import WarningIcon from '@mui/icons-material/Warning';
+
+interface ApplicationErrorProps {
+ message?: string;
+}
+
+const ApplicationError: FC = ({ message }) => (
+
+
+
+
+
+ Application Error
+
+
+
+ Failed to configure the application, please refresh to try again.
+
+ {message && (
+
+ {message}
+
+ )}
+
+
+);
+
+export default ApplicationError;
diff --git a/interface/src/components/loading/FormLoader.tsx b/interface/src/components/loading/FormLoader.tsx
new file mode 100644
index 000000000..8b1baab7b
--- /dev/null
+++ b/interface/src/components/loading/FormLoader.tsx
@@ -0,0 +1,38 @@
+import { FC } from 'react';
+
+import { Box, Button, CircularProgress, Typography } from '@mui/material';
+import RefreshIcon from '@mui/icons-material/Refresh';
+
+import { MessageBox } from '..';
+
+interface FormLoaderProps {
+ message?: string;
+ errorMessage?: string;
+ onRetry?: () => void;
+}
+
+const FormLoader: FC = ({ errorMessage, onRetry, message = 'Loading…' }) => {
+ if (errorMessage) {
+ return (
+
+ {onRetry && (
+ } variant="contained" color="error" onClick={onRetry}>
+ Retry
+
+ )}
+
+ );
+ }
+ return (
+
+
+
+
+
+ {message}
+
+
+ );
+};
+
+export default FormLoader;
diff --git a/interface/src/components/loading/LoadingSpinner.tsx b/interface/src/components/loading/LoadingSpinner.tsx
new file mode 100644
index 000000000..eaeccf0ad
--- /dev/null
+++ b/interface/src/components/loading/LoadingSpinner.tsx
@@ -0,0 +1,24 @@
+import { FC } from 'react';
+
+import { CircularProgress, Box, Typography, Theme } from '@mui/material';
+
+interface LoadingSpinnerProps {
+ height?: number | string;
+}
+
+const LoadingSpinner: FC = ({ height = '100%' }) => (
+
+ ({
+ margin: theme.spacing(4),
+ color: theme.palette.text.secondary
+ })}
+ size={100}
+ />
+
+ Loading…
+
+
+);
+
+export default LoadingSpinner;
diff --git a/interface/src/components/loading/index.ts b/interface/src/components/loading/index.ts
new file mode 100644
index 000000000..f8c7b8608
--- /dev/null
+++ b/interface/src/components/loading/index.ts
@@ -0,0 +1,3 @@
+export { default as ApplicationError } from './ApplicationError';
+export { default as LoadingSpinner } from './LoadingSpinner';
+export { default as FormLoader } from './FormLoader';
diff --git a/interface/src/components/routing/RequireAdmin.tsx b/interface/src/components/routing/RequireAdmin.tsx
new file mode 100644
index 000000000..204ba9660
--- /dev/null
+++ b/interface/src/components/routing/RequireAdmin.tsx
@@ -0,0 +1,12 @@
+import { FC, useContext } from 'react';
+import { Navigate } from 'react-router-dom';
+
+import { AuthenticatedContext } from '../../contexts/authentication';
+import { RequiredChildrenProps } from '../../utils';
+
+const RequireAdmin: FC = ({ children }) => {
+ const authenticatedContext = useContext(AuthenticatedContext);
+ return authenticatedContext.me.admin ? <>{children}> : ;
+};
+
+export default RequireAdmin;
diff --git a/interface/src/components/routing/RequireAuthenticated.tsx b/interface/src/components/routing/RequireAuthenticated.tsx
new file mode 100644
index 000000000..67abf7d9c
--- /dev/null
+++ b/interface/src/components/routing/RequireAuthenticated.tsx
@@ -0,0 +1,32 @@
+import { FC, useContext, useEffect } from 'react';
+import { Navigate, useLocation } from 'react-router-dom';
+
+import {
+ AuthenticatedContext,
+ AuthenticatedContextValue,
+ AuthenticationContext
+} from '../../contexts/authentication/context';
+import { storeLoginRedirect } from '../../api/authentication';
+
+import { RequiredChildrenProps } from '../../utils';
+
+const RequireAuthenticated: FC = ({ children }) => {
+ const authenticationContext = useContext(AuthenticationContext);
+ const location = useLocation();
+
+ useEffect(() => {
+ if (!authenticationContext.me) {
+ storeLoginRedirect(location);
+ }
+ });
+
+ return authenticationContext.me ? (
+
+ {children}
+
+ ) : (
+
+ );
+};
+
+export default RequireAuthenticated;
diff --git a/interface/src/components/routing/RequireUnauthenticated.tsx b/interface/src/components/routing/RequireUnauthenticated.tsx
new file mode 100644
index 000000000..43fee39d9
--- /dev/null
+++ b/interface/src/components/routing/RequireUnauthenticated.tsx
@@ -0,0 +1,16 @@
+import { FC, useContext } from 'react';
+import { Navigate } from 'react-router-dom';
+
+import * as AuthenticationApi from '../../api/authentication';
+import { AuthenticationContext } from '../../contexts/authentication';
+import { RequiredChildrenProps } from '../../utils';
+import { FeaturesContext } from '../../contexts/features';
+
+const RequireUnauthenticated: FC = ({ children }) => {
+ const { features } = useContext(FeaturesContext);
+ const authenticationContext = useContext(AuthenticationContext);
+
+ return authenticationContext.me ? : <>{children}>;
+};
+
+export default RequireUnauthenticated;
diff --git a/interface/src/components/routing/RouterTabs.tsx b/interface/src/components/routing/RouterTabs.tsx
new file mode 100644
index 000000000..4c0b16c68
--- /dev/null
+++ b/interface/src/components/routing/RouterTabs.tsx
@@ -0,0 +1,29 @@
+import React, { FC } from 'react';
+import { useNavigate } from 'react-router-dom';
+
+import { Tabs, useMediaQuery, useTheme } from '@mui/material';
+
+import { RequiredChildrenProps } from '../../utils';
+
+interface RouterTabsProps extends RequiredChildrenProps {
+ value: string | false;
+}
+
+const RouterTabs: FC = ({ value, children }) => {
+ const navigate = useNavigate();
+
+ const theme = useTheme();
+ const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
+
+ const handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
+ navigate(path);
+ };
+
+ return (
+
+ {children}
+
+ );
+};
+
+export default RouterTabs;
diff --git a/interface/src/components/routing/index.ts b/interface/src/components/routing/index.ts
new file mode 100644
index 000000000..84e2c217b
--- /dev/null
+++ b/interface/src/components/routing/index.ts
@@ -0,0 +1,6 @@
+export { default as RouterTabs } from './RouterTabs';
+export { default as RequireAdmin } from './RequireAdmin';
+export { default as RequireAuthenticated } from './RequireAuthenticated';
+export { default as RequireUnauthenticated } from './RequireUnauthenticated';
+
+export * from './useRouterTab';
diff --git a/interface/src/components/routing/useRouterTab.ts b/interface/src/components/routing/useRouterTab.ts
new file mode 100644
index 000000000..5dff86fd8
--- /dev/null
+++ b/interface/src/components/routing/useRouterTab.ts
@@ -0,0 +1,9 @@
+import { useMatch, useResolvedPath } from 'react-router-dom';
+
+export const useRouterTab = () => {
+ const routerTabPath = useResolvedPath(':tab');
+ const routerTabPathMatch = useMatch(routerTabPath.pathname);
+
+ const routerTab = routerTabPathMatch?.params?.tab || false;
+ return { routerTab } as const;
+};
diff --git a/interface/src/components/upload/SingleUpload.tsx b/interface/src/components/upload/SingleUpload.tsx
new file mode 100644
index 000000000..acdc15071
--- /dev/null
+++ b/interface/src/components/upload/SingleUpload.tsx
@@ -0,0 +1,93 @@
+import { FC, Fragment } from 'react';
+import { useDropzone, DropzoneState } from 'react-dropzone';
+
+import { Box, Button, LinearProgress, Theme, Typography, useTheme } from '@mui/material';
+
+import CloudUploadIcon from '@mui/icons-material/CloudUpload';
+import CancelIcon from '@mui/icons-material/Cancel';
+
+const progressPercentage = (progress: ProgressEvent) => Math.round((progress.loaded * 100) / progress.total);
+
+const getBorderColor = (theme: Theme, props: DropzoneState) => {
+ if (props.isDragAccept) {
+ return theme.palette.success.main;
+ }
+ if (props.isDragReject) {
+ return theme.palette.error.main;
+ }
+ if (props.isDragActive) {
+ return theme.palette.info.main;
+ }
+ return theme.palette.grey[700];
+};
+
+export interface SingleUploadProps {
+ onDrop: (acceptedFiles: File[]) => void;
+ onCancel: () => void;
+ uploading: boolean;
+ progress?: ProgressEvent;
+}
+
+const SingleUpload: FC = ({ onDrop, onCancel, uploading, progress }) => {
+ const dropzoneState = useDropzone({
+ onDrop,
+ accept: {
+ 'application/octet-stream': ['.bin'],
+ 'application/json': ['.json']
+ },
+ disabled: uploading,
+ multiple: false
+ });
+ const { getRootProps, getInputProps } = dropzoneState;
+ const theme = useTheme();
+
+ const progressText = () => {
+ if (uploading) {
+ if (progress?.lengthComputable) {
+ return `Uploading: ${progressPercentage(progress)}%`;
+ }
+ return 'Uploading\u2026';
+ }
+ return 'Drop file or click here';
+ };
+
+ return (
+
+
+
+
+ {progressText()}
+ {uploading && (
+
+
+
+
+ } variant="outlined" color="secondary" onClick={onCancel}>
+ Cancel
+
+
+ )}
+
+
+ );
+};
+
+export default SingleUpload;
diff --git a/interface/src/components/upload/index.ts b/interface/src/components/upload/index.ts
new file mode 100644
index 000000000..e1589c5a1
--- /dev/null
+++ b/interface/src/components/upload/index.ts
@@ -0,0 +1,2 @@
+export { default as SingleUpload } from './SingleUpload';
+export { default as useFileUpload } from './useFileUpload';
diff --git a/interface/src/components/upload/useFileUpload.ts b/interface/src/components/upload/useFileUpload.ts
new file mode 100644
index 000000000..0cc12b1aa
--- /dev/null
+++ b/interface/src/components/upload/useFileUpload.ts
@@ -0,0 +1,59 @@
+import { useCallback, useEffect, useState } from 'react';
+import axios, { AxiosPromise, CancelTokenSource } from 'axios';
+import { useSnackbar } from 'notistack';
+
+import { extractErrorMessage } from '../../utils';
+import { FileUploadConfig } from '../../api/endpoints';
+
+interface MediaUploadOptions {
+ upload: (file: File, config?: FileUploadConfig) => AxiosPromise;
+}
+
+const useFileUpload = ({ upload }: MediaUploadOptions) => {
+ const { enqueueSnackbar } = useSnackbar();
+ const [uploading, setUploading] = useState(false);
+ const [uploadProgress, setUploadProgress] = useState();
+ const [uploadCancelToken, setUploadCancelToken] = useState();
+
+ const resetUploadingStates = () => {
+ setUploading(false);
+ setUploadProgress(undefined);
+ setUploadCancelToken(undefined);
+ };
+
+ const cancelUpload = useCallback(() => {
+ uploadCancelToken?.cancel();
+ resetUploadingStates();
+ }, [uploadCancelToken]);
+
+ useEffect(() => {
+ return () => {
+ uploadCancelToken?.cancel();
+ };
+ }, [uploadCancelToken]);
+
+ const uploadFile = async (images: File[]) => {
+ try {
+ const cancelToken = axios.CancelToken.source();
+ setUploadCancelToken(cancelToken);
+ setUploading(true);
+ await upload(images[0], {
+ onUploadProgress: setUploadProgress,
+ cancelToken: cancelToken.token
+ });
+ resetUploadingStates();
+ enqueueSnackbar('File uploaded', { variant: 'success' });
+ } catch (error: unknown) {
+ if (axios.isCancel(error)) {
+ enqueueSnackbar('Upload aborted', { variant: 'warning' });
+ } else {
+ resetUploadingStates();
+ enqueueSnackbar(extractErrorMessage(error, 'Upload failed'), { variant: 'error' });
+ }
+ }
+ };
+
+ return [uploadFile, cancelUpload, uploading, uploadProgress] as const;
+};
+
+export default useFileUpload;
diff --git a/interface/src/contexts/authentication/Authentication.tsx b/interface/src/contexts/authentication/Authentication.tsx
new file mode 100644
index 000000000..a1310e894
--- /dev/null
+++ b/interface/src/contexts/authentication/Authentication.tsx
@@ -0,0 +1,85 @@
+import { FC, useCallback, useContext, useEffect, useState } from 'react';
+import { useSnackbar } from 'notistack';
+import { useNavigate } from 'react-router-dom';
+
+import * as AuthenticationApi from '../../api/authentication';
+import { ACCESS_TOKEN } from '../../api/endpoints';
+import { RequiredChildrenProps } from '../../utils';
+import { LoadingSpinner } from '../../components';
+import { Me } from '../../types';
+import { FeaturesContext } from '../features';
+import { AuthenticationContext } from './context';
+
+const Authentication: FC = ({ children }) => {
+ const { features } = useContext(FeaturesContext);
+ const navigate = useNavigate();
+ const { enqueueSnackbar } = useSnackbar();
+
+ const [initialized, setInitialized] = useState(false);
+ const [me, setMe] = useState();
+
+ const signIn = (accessToken: string) => {
+ try {
+ AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
+ const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
+ setMe(decodedMe);
+ enqueueSnackbar(`Logged in as ${decodedMe.username}`, { variant: 'success' });
+ } catch (error: unknown) {
+ setMe(undefined);
+ throw new Error('Failed to parse JWT');
+ }
+ };
+
+ const signOut = (redirect: boolean) => {
+ AuthenticationApi.clearAccessToken();
+ setMe(undefined);
+ if (redirect) {
+ navigate('/');
+ }
+ };
+
+ const refresh = useCallback(async () => {
+ if (!features.security) {
+ setMe({ admin: true, username: 'admin' });
+ setInitialized(true);
+ return;
+ }
+ const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
+ if (accessToken) {
+ try {
+ await AuthenticationApi.verifyAuthorization();
+ setMe(AuthenticationApi.decodeMeJWT(accessToken));
+ setInitialized(true);
+ } catch (error: unknown) {
+ setMe(undefined);
+ setInitialized(true);
+ }
+ } else {
+ setMe(undefined);
+ setInitialized(true);
+ }
+ }, [features]);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ if (initialized) {
+ return (
+
+ {children}
+
+ );
+ }
+
+ return ;
+};
+
+export default Authentication;
diff --git a/interface/src/contexts/authentication/context.ts b/interface/src/contexts/authentication/context.ts
new file mode 100644
index 000000000..7f4b79c7d
--- /dev/null
+++ b/interface/src/contexts/authentication/context.ts
@@ -0,0 +1,19 @@
+import { createContext } from 'react';
+import { Me } from '../../types';
+
+export interface AuthenticationContextValue {
+ refresh: () => Promise;
+ signIn: (accessToken: string) => void;
+ signOut: (redirect: boolean) => void;
+ me?: Me;
+}
+
+const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;
+export const AuthenticationContext = createContext(AuthenticationContextDefaultValue);
+
+export interface AuthenticatedContextValue extends AuthenticationContextValue {
+ me: Me;
+}
+
+const AuthenticatedContextDefaultValue = {} as AuthenticatedContextValue;
+export const AuthenticatedContext = createContext(AuthenticatedContextDefaultValue);
diff --git a/interface/src/contexts/authentication/index.ts b/interface/src/contexts/authentication/index.ts
new file mode 100644
index 000000000..da20f43e3
--- /dev/null
+++ b/interface/src/contexts/authentication/index.ts
@@ -0,0 +1,2 @@
+export * from './context';
+export { default as Authentication } from './Authentication';
diff --git a/interface/src/contexts/features/FeaturesLoader.tsx b/interface/src/contexts/features/FeaturesLoader.tsx
new file mode 100644
index 000000000..ed5dd73c0
--- /dev/null
+++ b/interface/src/contexts/features/FeaturesLoader.tsx
@@ -0,0 +1,47 @@
+import { FC, useCallback, useEffect, useState } from 'react';
+
+import * as FeaturesApi from '../../api/features';
+
+import { extractErrorMessage, RequiredChildrenProps } from '../../utils';
+import { Features } from '../../types';
+import { ApplicationError, LoadingSpinner } from '../../components';
+
+import { FeaturesContext } from '.';
+
+const FeaturesLoader: FC = (props) => {
+ const [errorMessage, setErrorMessage] = useState();
+ const [features, setFeatures] = useState();
+
+ const loadFeatures = useCallback(async () => {
+ try {
+ const response = await FeaturesApi.readFeatures();
+ setFeatures(response.data);
+ } catch (error: unknown) {
+ setErrorMessage(extractErrorMessage(error, 'Failed to fetch application details.'));
+ }
+ }, []);
+
+ useEffect(() => {
+ loadFeatures();
+ }, [loadFeatures]);
+
+ if (features) {
+ return (
+
+ {props.children}
+
+ );
+ }
+
+ if (errorMessage) {
+ return ;
+ }
+
+ return ;
+};
+
+export default FeaturesLoader;
diff --git a/interface/src/contexts/features/context.ts b/interface/src/contexts/features/context.ts
new file mode 100644
index 000000000..66e0e8550
--- /dev/null
+++ b/interface/src/contexts/features/context.ts
@@ -0,0 +1,10 @@
+import { createContext } from 'react';
+
+import { Features } from '../../types';
+
+export interface FeaturesContextValue {
+ features: Features;
+}
+
+const FeaturesContextDefaultValue = {} as FeaturesContextValue;
+export const FeaturesContext = createContext(FeaturesContextDefaultValue);
diff --git a/interface/src/contexts/features/index.ts b/interface/src/contexts/features/index.ts
new file mode 100644
index 000000000..f8df83c02
--- /dev/null
+++ b/interface/src/contexts/features/index.ts
@@ -0,0 +1,2 @@
+export * from './context';
+export { default as FeaturesLoader } from './FeaturesLoader';
diff --git a/interface/src/features/FeaturesContext.tsx b/interface/src/features/FeaturesContext.tsx
deleted file mode 100644
index 15296e085..000000000
--- a/interface/src/features/FeaturesContext.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import React from 'react';
-import { Features } from './types';
-
-export interface FeaturesContextValue {
- features: Features;
-}
-
-const FeaturesContextDefaultValue = {} as FeaturesContextValue;
-export const FeaturesContext = React.createContext(FeaturesContextDefaultValue);
-
-export interface WithFeaturesProps {
- features: Features;
-}
-
-export function withFeatures(
- Component: React.ComponentType
-) {
- return class extends React.Component> {
- render() {
- return (
-
- {(featuresContext) => (
-
- )}
-
- );
- }
- };
-}
diff --git a/interface/src/features/FeaturesWrapper.tsx b/interface/src/features/FeaturesWrapper.tsx
deleted file mode 100644
index e5c762f23..000000000
--- a/interface/src/features/FeaturesWrapper.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import { FC } from 'react';
-
-import FullScreenLoading from '../components/FullScreenLoading';
-import ApplicationError from '../components/ApplicationError';
-import { FEATURES_ENDPOINT } from '../api';
-import { useRest } from '../hooks';
-
-import { Features } from './types';
-import { FeaturesContext } from './FeaturesContext';
-
-const FeaturesWrapper: FC = ({ children }) => {
- const { data: features, errorMessage: error } = useRest({
- endpoint: FEATURES_ENDPOINT
- });
-
- if (features) {
- return (
-
- {children}
-
- );
- }
-
- if (error) {
- return ;
- }
-
- return ;
-};
-
-export default FeaturesWrapper;
diff --git a/interface/src/framework/ap/APSettingsForm.tsx b/interface/src/framework/ap/APSettingsForm.tsx
new file mode 100644
index 000000000..8080f4f0f
--- /dev/null
+++ b/interface/src/framework/ap/APSettingsForm.tsx
@@ -0,0 +1,185 @@
+import { FC, useState } from 'react';
+import { ValidateFieldsError } from 'async-validator';
+import { range } from 'lodash';
+
+import { Button, Checkbox, MenuItem } from '@mui/material';
+import SaveIcon from '@mui/icons-material/Save';
+
+import { createAPSettingsValidator, validate } from '../../validators';
+import {
+ BlockFormControlLabel,
+ ButtonRow,
+ FormLoader,
+ SectionContent,
+ ValidatedPasswordField,
+ ValidatedTextField
+} from '../../components';
+
+import { APProvisionMode, APSettings } from '../../types';
+import { numberValue, updateValue, useRest } from '../../utils';
+import * as APApi from '../../api/ap';
+
+export const isAPEnabled = ({ provision_mode }: APSettings) => {
+ return provision_mode === APProvisionMode.AP_MODE_ALWAYS || provision_mode === APProvisionMode.AP_MODE_DISCONNECTED;
+};
+
+const APSettingsForm: FC = () => {
+ const { loadData, saving, data, setData, saveData, errorMessage } = useRest({
+ read: APApi.readAPSettings,
+ update: APApi.updateAPSettings
+ });
+
+ const [fieldErrors, setFieldErrors] = useState();
+
+ const updateFormValue = updateValue(setData);
+
+ const content = () => {
+ if (!data) {
+ return ;
+ }
+
+ const validateAndSubmit = async () => {
+ try {
+ setFieldErrors(undefined);
+ await validate(createAPSettingsValidator(data), data);
+ saveData();
+ } catch (errors: any) {
+ setFieldErrors(errors);
+ }
+ };
+
+ return (
+ <>
+
+
+
+
+
+ {isAPEnabled(data) && (
+ <>
+
+
+
+ {range(1, 14).map((i) => (
+
+ ))}
+
+ }
+ label="Hide SSID"
+ />
+
+ {range(1, 9).map((i) => (
+
+ ))}
+
+
+
+
+ >
+ )}
+
+ }
+ disabled={saving}
+ variant="outlined"
+ color="primary"
+ type="submit"
+ onClick={validateAndSubmit}
+ >
+ Save
+
+
+ >
+ );
+ };
+
+ return (
+
+ {content()}
+
+ );
+};
+
+export default APSettingsForm;
diff --git a/interface/src/framework/ap/APStatusForm.tsx b/interface/src/framework/ap/APStatusForm.tsx
new file mode 100644
index 000000000..e0a99e3b2
--- /dev/null
+++ b/interface/src/framework/ap/APStatusForm.tsx
@@ -0,0 +1,104 @@
+import { FC } from 'react';
+
+import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, Theme, useTheme } from '@mui/material';
+import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
+import DeviceHubIcon from '@mui/icons-material/DeviceHub';
+import ComputerIcon from '@mui/icons-material/Computer';
+import RefreshIcon from '@mui/icons-material/Refresh';
+
+import * as APApi from '../../api/ap';
+import { APNetworkStatus, APStatus } from '../../types';
+import { ButtonRow, FormLoader, SectionContent } from '../../components';
+import { useRest } from '../../utils';
+
+export const apStatusHighlight = ({ status }: APStatus, theme: Theme) => {
+ switch (status) {
+ case APNetworkStatus.ACTIVE:
+ return theme.palette.success.main;
+ case APNetworkStatus.INACTIVE:
+ return theme.palette.info.main;
+ case APNetworkStatus.LINGERING:
+ return theme.palette.warning.main;
+ default:
+ return theme.palette.warning.main;
+ }
+};
+
+export const apStatus = ({ status }: APStatus) => {
+ switch (status) {
+ case APNetworkStatus.ACTIVE:
+ return 'Active';
+ case APNetworkStatus.INACTIVE:
+ return 'Inactive';
+ case APNetworkStatus.LINGERING:
+ return 'Lingering until idle';
+ default:
+ return 'Unknown';
+ }
+};
+
+const APStatusForm: FC = () => {
+ const { loadData, data, errorMessage } = useRest({ read: APApi.readAPStatus });
+
+ const theme = useTheme();
+
+ const content = () => {
+ if (!data) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ IP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } variant="outlined" color="secondary" onClick={loadData}>
+ Refresh
+
+
+ >
+ );
+ };
+
+ return (
+
+ {content()}
+
+ );
+};
+
+export default APStatusForm;
diff --git a/interface/src/framework/ap/AccessPoint.tsx b/interface/src/framework/ap/AccessPoint.tsx
new file mode 100644
index 000000000..742db7b24
--- /dev/null
+++ b/interface/src/framework/ap/AccessPoint.tsx
@@ -0,0 +1,40 @@
+import { FC, useContext } from 'react';
+import { Navigate, Routes, Route } from 'react-router-dom';
+
+import { Tab } from '@mui/material';
+
+import { AuthenticatedContext } from '../../contexts/authentication';
+import APStatusForm from './APStatusForm';
+import APSettingsForm from './APSettingsForm';
+import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
+
+const AccessPoint: FC = () => {
+ useLayoutTitle('Access Point');
+
+ const authenticatedContext = useContext(AuthenticatedContext);
+
+ const { routerTab } = useRouterTab();
+
+ return (
+ <>
+
+
+
+
+
+ } />
+
+
+
+ }
+ />
+ } />
+
+ >
+ );
+};
+
+export default AccessPoint;
diff --git a/interface/src/framework/mqtt/Mqtt.tsx b/interface/src/framework/mqtt/Mqtt.tsx
new file mode 100644
index 000000000..d44edb781
--- /dev/null
+++ b/interface/src/framework/mqtt/Mqtt.tsx
@@ -0,0 +1,40 @@
+import React, { FC, useContext } from 'react';
+import { Navigate, Route, Routes } from 'react-router-dom';
+
+import { Tab } from '@mui/material';
+
+import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
+import { AuthenticatedContext } from '../../contexts/authentication';
+
+import MqttStatusForm from './MqttStatusForm';
+import MqttSettingsForm from './MqttSettingsForm';
+
+const Mqtt: FC = () => {
+ useLayoutTitle('MQTT');
+
+ const authenticatedContext = useContext(AuthenticatedContext);
+ const { routerTab } = useRouterTab();
+
+ return (
+ <>
+
+
+
+
+
+ } />
+
+
+
+ }
+ />
+ } />
+
+ >
+ );
+};
+
+export default Mqtt;
diff --git a/interface/src/framework/mqtt/MqttSettingsForm.tsx b/interface/src/framework/mqtt/MqttSettingsForm.tsx
new file mode 100644
index 000000000..5e41ddfe0
--- /dev/null
+++ b/interface/src/framework/mqtt/MqttSettingsForm.tsx
@@ -0,0 +1,335 @@
+import { FC, useState } from 'react';
+import { ValidateFieldsError } from 'async-validator';
+
+import { Button, Checkbox, MenuItem, Grid, Typography } from '@mui/material';
+import SaveIcon from '@mui/icons-material/Save';
+
+import { MQTT_SETTINGS_VALIDATOR, validate } from '../../validators';
+import {
+ BlockFormControlLabel,
+ ButtonRow,
+ FormLoader,
+ SectionContent,
+ ValidatedPasswordField,
+ ValidatedTextField
+} from '../../components';
+import { MqttSettings } from '../../types';
+import { numberValue, updateValue, useRest } from '../../utils';
+import * as MqttApi from '../../api/mqtt';
+
+const MqttSettingsForm: FC = () => {
+ const { loadData, saving, data, setData, saveData, errorMessage } = useRest({
+ read: MqttApi.readMqttSettings,
+ update: MqttApi.updateMqttSettings
+ });
+
+ const [fieldErrors, setFieldErrors] = useState();
+
+ const updateFormValue = updateValue(setData);
+
+ const content = () => {
+ if (!data) {
+ return ;
+ }
+
+ const validateAndSubmit = async () => {
+ try {
+ setFieldErrors(undefined);
+ await validate(MQTT_SETTINGS_VALIDATOR, data);
+ saveData();
+ } catch (errors: any) {
+ setFieldErrors(errors);
+ }
+ };
+
+ return (
+ <>
+ }
+ label="Enable MQTT"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ label="Set Clean Session"
+ />
+ }
+ label="Always use Retain Flag"
+ />
+
+ Formatting
+
+
+
+
+
+ }
+ label="Publish command output to a 'response' topic"
+ />
+ {!data.ha_enabled && (
+
+
+ }
+ label="Publish single value topics on change"
+ />
+
+ {data.publish_single && (
+
+
+ }
+ label="Publish to command topics (ioBroker)"
+ />
+
+ )}
+
+ )}
+ {!data.publish_single && (
+
+
+ }
+ label="Enable MQTT Discovery (Home Assistant, Domoticz)"
+ />
+
+ {data.ha_enabled && (
+
+
+
+ )}
+
+ )}
+
+ Publish Intervals (in seconds, 0=automatic)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ disabled={saving}
+ variant="outlined"
+ color="primary"
+ type="submit"
+ onClick={validateAndSubmit}
+ >
+ Save
+
+
+ >
+ );
+ };
+
+ return (
+
+ {content()}
+
+ );
+};
+
+export default MqttSettingsForm;
diff --git a/interface/src/framework/mqtt/MqttStatusForm.tsx b/interface/src/framework/mqtt/MqttStatusForm.tsx
new file mode 100644
index 000000000..b39fff4c7
--- /dev/null
+++ b/interface/src/framework/mqtt/MqttStatusForm.tsx
@@ -0,0 +1,142 @@
+import { FC } from 'react';
+import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, Theme, useTheme } from '@mui/material';
+
+import DeviceHubIcon from '@mui/icons-material/DeviceHub';
+import RefreshIcon from '@mui/icons-material/Refresh';
+import ReportIcon from '@mui/icons-material/Report';
+import SpeakerNotesOffIcon from '@mui/icons-material/SpeakerNotesOff';
+
+import { ButtonRow, FormLoader, SectionContent } from '../../components';
+import { MqttStatus, MqttDisconnectReason } from '../../types';
+import * as MqttApi from '../../api/mqtt';
+import { useRest } from '../../utils';
+
+export const mqttStatusHighlight = ({ enabled, connected }: MqttStatus, theme: Theme) => {
+ if (!enabled) {
+ return theme.palette.info.main;
+ }
+ if (connected) {
+ return theme.palette.success.main;
+ }
+ return theme.palette.error.main;
+};
+
+export const mqttPublishHighlight = ({ mqtt_fails }: MqttStatus, theme: Theme) => {
+ if (mqtt_fails === 0) return theme.palette.success.main;
+
+ if (mqtt_fails < 10) return theme.palette.warning.main;
+
+ return theme.palette.error.main;
+};
+
+export const mqttStatus = ({ enabled, connected }: MqttStatus) => {
+ if (!enabled) {
+ return 'Not enabled';
+ }
+ if (connected) {
+ return 'Connected';
+ }
+ return 'Disconnected';
+};
+
+export const disconnectReason = ({ disconnect_reason }: MqttStatus) => {
+ switch (disconnect_reason) {
+ case MqttDisconnectReason.TCP_DISCONNECTED:
+ return 'TCP disconnected';
+ case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
+ return 'Unacceptable protocol version';
+ case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
+ return 'Client ID rejected';
+ case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
+ return 'Server unavailable';
+ case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
+ return 'Malformed credentials';
+ case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
+ return 'Not authorized';
+ case MqttDisconnectReason.ESP8266_NOT_ENOUGH_SPACE:
+ return 'Device out of memory';
+ case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
+ return 'Server fingerprint invalid';
+ default:
+ return 'Unknown';
+ }
+};
+
+const MqttStatusForm: FC = () => {
+ const { loadData, data, errorMessage } = useRest({ read: MqttApi.readMqttStatus });
+
+ const theme = useTheme();
+
+ const content = () => {
+ if (!data) {
+ return ;
+ }
+
+ const renderConnectionStatus = () => {
+ if (data.connected) {
+ return (
+ <>
+
+
+ #
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ );
+ }
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ );
+ };
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {data.enabled && renderConnectionStatus()}
+
+
+ } variant="outlined" color="secondary" onClick={loadData}>
+ Refresh
+
+
+ >
+ );
+ };
+
+ return (
+
+ {content()}
+
+ );
+};
+
+export default MqttStatusForm;
diff --git a/interface/src/framework/network/NetworkConnection.tsx b/interface/src/framework/network/NetworkConnection.tsx
new file mode 100644
index 000000000..27bf6d8cb
--- /dev/null
+++ b/interface/src/framework/network/NetworkConnection.tsx
@@ -0,0 +1,72 @@
+import React, { FC, useCallback, useContext, useState } from 'react';
+import { Navigate, Routes, Route, useNavigate } from 'react-router-dom';
+
+import { Tab } from '@mui/material';
+
+import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
+import { WiFiNetwork } from '../../types';
+import { AuthenticatedContext } from '../../contexts/authentication';
+import { WiFiConnectionContext } from './WiFiConnectionContext';
+import NetworkStatusForm from './NetworkStatusForm';
+import WiFiNetworkScanner from './WiFiNetworkScanner';
+import NetworkSettingsForm from './NetworkSettingsForm';
+
+const NetworkConnection: FC = () => {
+ useLayoutTitle('Network Connection');
+
+ const authenticatedContext = useContext(AuthenticatedContext);
+ const navigate = useNavigate();
+ const { routerTab } = useRouterTab();
+
+ const [selectedNetwork, setSelectedNetwork] = useState();
+
+ const selectNetwork = useCallback(
+ (network: WiFiNetwork) => {
+ setSelectedNetwork(network);
+ navigate('settings');
+ },
+ [navigate]
+ );
+
+ const deselectNetwork = useCallback(() => {
+ setSelectedNetwork(undefined);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+ } />
+
+
+
+ }
+ />
+
+
+
+ }
+ />
+ } />
+
+
+ );
+};
+
+export default NetworkConnection;
diff --git a/interface/src/framework/network/NetworkSettingsForm.tsx b/interface/src/framework/network/NetworkSettingsForm.tsx
new file mode 100644
index 000000000..c1221713d
--- /dev/null
+++ b/interface/src/framework/network/NetworkSettingsForm.tsx
@@ -0,0 +1,263 @@
+import { FC, useContext, useEffect, useState } from 'react';
+
+import {
+ Avatar,
+ Button,
+ Checkbox,
+ IconButton,
+ List,
+ ListItem,
+ ListItemAvatar,
+ ListItemSecondaryAction,
+ ListItemText,
+ Typography
+} from '@mui/material';
+
+import LockOpenIcon from '@mui/icons-material/LockOpen';
+import DeleteIcon from '@mui/icons-material/Delete';
+import SaveIcon from '@mui/icons-material/Save';
+import LockIcon from '@mui/icons-material/Lock';
+
+import {
+ BlockFormControlLabel,
+ ButtonRow,
+ FormLoader,
+ SectionContent,
+ ValidatedPasswordField,
+ ValidatedTextField
+} from '../../components';
+import { NetworkSettings } from '../../types';
+import * as NetworkApi from '../../api/network';
+import { numberValue, updateValue, useRest } from '../../utils';
+
+import { WiFiConnectionContext } from './WiFiConnectionContext';
+import { isNetworkOpen, networkSecurityMode } from './WiFiNetworkSelector';
+import { ValidateFieldsError } from 'async-validator';
+import { validate } from '../../validators';
+import { createNetworkSettingsValidator } from '../../validators/network';
+
+const WiFiSettingsForm: FC = () => {
+ const { selectedNetwork, deselectNetwork } = useContext(WiFiConnectionContext);
+
+ const [initialized, setInitialized] = useState(false);
+ const { loadData, saving, data, setData, saveData, errorMessage } = useRest({
+ read: NetworkApi.readNetworkSettings,
+ update: NetworkApi.updateNetworkSettings
+ });
+
+ useEffect(() => {
+ if (!initialized && data) {
+ if (selectedNetwork) {
+ setData({
+ ssid: selectedNetwork.ssid,
+ password: '',
+ hostname: data?.hostname,
+ static_ip_config: false,
+ enableIPv6: false,
+ bandwidth20: false,
+ tx_power: 20,
+ nosleep: false,
+ enableMDNS: true
+ });
+ }
+ setInitialized(true);
+ }
+ }, [initialized, setInitialized, data, setData, selectedNetwork]);
+
+ const updateFormValue = updateValue(setData);
+
+ const [fieldErrors, setFieldErrors] = useState();
+
+ useEffect(() => deselectNetwork, [deselectNetwork]);
+
+ const content = () => {
+ if (!data) {
+ return ;
+ }
+
+ const validateAndSubmit = async () => {
+ try {
+ setFieldErrors(undefined);
+ await validate(createNetworkSettingsValidator(data), data);
+ saveData();
+ } catch (errors: any) {
+ setFieldErrors(errors);
+ }
+ };
+
+ return (
+ <>
+
+ WiFi
+
+ {selectedNetwork ? (
+
+
+
+ {isNetworkOpen(selectedNetwork) ? : }
+
+
+
+
+
+
+
+
+
+ ) : (
+
+ )}
+ {(!selectedNetwork || !isNetworkOpen(selectedNetwork)) && (
+
+ )}
+
+
+
+ }
+ label="Disable WiFi Sleep Mode"
+ />
+
+ }
+ label="Use Lower WiFi Bandwidth"
+ />
+
+ }
+ label="Enable mDNS Service"
+ />
+
+
+ General
+
+
+
+
+ }
+ label="Enable IPv6 support"
+ />
+
+ }
+ label="Use Fixed IP address"
+ />
+ {data.static_ip_config && (
+ <>
+
+
+
+
+
+ >
+ )}
+
+ }
+ disabled={saving}
+ variant="outlined"
+ color="primary"
+ type="submit"
+ onClick={validateAndSubmit}
+ >
+ Save
+
+
+ >
+ );
+ };
+
+ return (
+
+ {content()}
+
+ );
+};
+
+export default WiFiSettingsForm;
diff --git a/interface/src/framework/network/NetworkStatusForm.tsx b/interface/src/framework/network/NetworkStatusForm.tsx
new file mode 100644
index 000000000..986b60d14
--- /dev/null
+++ b/interface/src/framework/network/NetworkStatusForm.tsx
@@ -0,0 +1,179 @@
+import { FC } from 'react';
+import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, Theme, useTheme } from '@mui/material';
+
+import SettingsInputComponentIcon from '@mui/icons-material/SettingsInputComponent';
+import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
+import DeviceHubIcon from '@mui/icons-material/DeviceHub';
+import WifiIcon from '@mui/icons-material/Wifi';
+import DnsIcon from '@mui/icons-material/Dns';
+import RefreshIcon from '@mui/icons-material/Refresh';
+import RouterIcon from '@mui/icons-material/Router';
+
+import { ButtonRow, FormLoader, SectionContent } from '../../components';
+import { NetworkConnectionStatus, NetworkStatus } from '../../types';
+import * as NetworkApi from '../../api/network';
+import { useRest } from '../../utils';
+
+const isConnected = ({ status }: NetworkStatus) =>
+ status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
+ status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
+
+const networkStatusHighlight = ({ status }: NetworkStatus, theme: Theme) => {
+ switch (status) {
+ case NetworkConnectionStatus.WIFI_STATUS_IDLE:
+ case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
+ case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
+ return theme.palette.info.main;
+ case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
+ case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
+ return theme.palette.success.main;
+ case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
+ case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
+ return theme.palette.error.main;
+ default:
+ return theme.palette.warning.main;
+ }
+};
+
+const networkStatus = ({ status }: NetworkStatus) => {
+ switch (status) {
+ case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
+ return 'Inactive';
+ case NetworkConnectionStatus.WIFI_STATUS_IDLE:
+ return 'Idle';
+ case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
+ return 'No SSID Available';
+ case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
+ return 'Connected (WiFi)';
+ case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
+ return 'Connected (Ethernet)';
+ case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
+ return 'Connection Failed';
+ case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
+ return 'Connection Lost';
+ case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
+ return 'Disconnected';
+ default:
+ return 'Unknown';
+ }
+};
+
+export const isWiFi = ({ status }: NetworkStatus) => status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
+export const isEthernet = ({ status }: NetworkStatus) => status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
+
+const dnsServers = ({ dns_ip_1, dns_ip_2 }: NetworkStatus) => {
+ if (!dns_ip_1) {
+ return 'none';
+ }
+ return dns_ip_1 + (dns_ip_2 === '0.0.0.0' ? '' : ',' + dns_ip_2);
+};
+
+const IPs = (status: NetworkStatus) => {
+ if (!status.local_ipv6 || status.local_ipv6 === '0000:0000:0000:0000:0000:0000:0000:0000') {
+ return status.local_ip;
+ }
+ if (!status.local_ip || status.local_ip === '0.0.0.0') {
+ return status.local_ipv6;
+ }
+ return status.local_ip + ', ' + status.local_ipv6;
+};
+
+const NetworkStatusForm: FC = () => {
+ const { loadData, data, errorMessage } = useRest({ read: NetworkApi.readNetworkStatus });
+
+ const theme = useTheme();
+
+ const content = () => {
+ if (!data) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+
+ {isWiFi(data) && }
+ {isEthernet(data) && }
+
+
+
+
+
+ {isWiFi(data) && (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {isConnected(data) && (
+ <>
+
+
+ IP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+ } variant="outlined" color="secondary" onClick={loadData}>
+ Refresh
+
+
+ >
+ );
+ };
+
+ return (
+
+ {content()}
+
+ );
+};
+
+export default NetworkStatusForm;
diff --git a/interface/src/framework/network/WiFiConnectionContext.tsx b/interface/src/framework/network/WiFiConnectionContext.tsx
new file mode 100644
index 000000000..db2e1b2af
--- /dev/null
+++ b/interface/src/framework/network/WiFiConnectionContext.tsx
@@ -0,0 +1,11 @@
+import { createContext } from 'react';
+import { WiFiNetwork } from '../../types';
+
+export interface WiFiConnectionContextValue {
+ selectedNetwork?: WiFiNetwork;
+ selectNetwork: (network: WiFiNetwork) => void;
+ deselectNetwork: () => void;
+}
+
+const WiFiConnectionContextDefaultValue = {} as WiFiConnectionContextValue;
+export const WiFiConnectionContext = createContext(WiFiConnectionContextDefaultValue);
diff --git a/interface/src/framework/network/WiFiNetworkScanner.tsx b/interface/src/framework/network/WiFiNetworkScanner.tsx
new file mode 100644
index 000000000..169bc6951
--- /dev/null
+++ b/interface/src/framework/network/WiFiNetworkScanner.tsx
@@ -0,0 +1,110 @@
+import { useEffect, FC, useState, useCallback, useRef } from 'react';
+import { useSnackbar } from 'notistack';
+
+import { AxiosError } from 'axios';
+
+import { Button } from '@mui/material';
+import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
+
+import * as NetworkApi from '../../api/network';
+import { WiFiNetwork, WiFiNetworkList } from '../../types';
+import { ButtonRow, FormLoader, SectionContent } from '../../components';
+
+import WiFiNetworkSelector from './WiFiNetworkSelector';
+
+const NUM_POLLS = 10;
+const POLLING_FREQUENCY = 500;
+
+const compareNetworks = (network1: WiFiNetwork, network2: WiFiNetwork) => {
+ if (network1.rssi < network2.rssi) return 1;
+ if (network1.rssi > network2.rssi) return -1;
+ return 0;
+};
+
+const WiFiNetworkScanner: FC = () => {
+ const { enqueueSnackbar } = useSnackbar();
+
+ const pollCount = useRef(0);
+ const [networkList, setNetworkList] = useState();
+ const [errorMessage, setErrorMessage] = useState();
+
+ const finishedWithError = useCallback(
+ (message: string) => {
+ enqueueSnackbar(message, { variant: 'error' });
+ setNetworkList(undefined);
+ setErrorMessage(message);
+ },
+ [enqueueSnackbar]
+ );
+
+ const pollNetworkList = useCallback(async () => {
+ try {
+ const response = await NetworkApi.listNetworks();
+ if (response.status === 202) {
+ const completedPollCount = pollCount.current + 1;
+ if (completedPollCount < NUM_POLLS) {
+ pollCount.current = completedPollCount;
+ setTimeout(pollNetworkList, POLLING_FREQUENCY);
+ } else {
+ finishedWithError('Device did not return network list in timely manner');
+ }
+ } else {
+ const newNetworkList = response.data;
+ newNetworkList.networks.sort(compareNetworks);
+ setNetworkList(newNetworkList);
+ }
+ } catch (error: unknown) {
+ if (error instanceof AxiosError) {
+ finishedWithError('Problem listing WiFi networks ' + error.response?.data.message);
+ } else {
+ finishedWithError('Problem listing WiFi networks');
+ }
+ }
+ }, [finishedWithError]);
+
+ const startNetworkScan = useCallback(async () => {
+ pollCount.current = 0;
+ setNetworkList(undefined);
+ setErrorMessage(undefined);
+ try {
+ await NetworkApi.scanNetworks();
+ setTimeout(pollNetworkList, POLLING_FREQUENCY);
+ } catch (error: unknown) {
+ if (error instanceof AxiosError) {
+ finishedWithError('Problem scanning for WiFi networks ' + error.response?.data.message);
+ } else {
+ finishedWithError('Problem scanning for WiFi networks');
+ }
+ }
+ }, [finishedWithError, pollNetworkList]);
+
+ useEffect(() => {
+ startNetworkScan();
+ }, [startNetworkScan]);
+
+ const renderNetworkScanner = () => {
+ if (!networkList) {
+ return ;
+ }
+ return ;
+ };
+
+ return (
+
+ {renderNetworkScanner()}
+
+ }
+ variant="outlined"
+ color="secondary"
+ onClick={startNetworkScan}
+ disabled={!errorMessage && !networkList}
+ >
+ Scan again…
+
+
+
+ );
+};
+
+export default WiFiNetworkScanner;
diff --git a/interface/src/framework/network/WiFiNetworkSelector.tsx b/interface/src/framework/network/WiFiNetworkSelector.tsx
new file mode 100644
index 000000000..960289ef6
--- /dev/null
+++ b/interface/src/framework/network/WiFiNetworkSelector.tsx
@@ -0,0 +1,70 @@
+import { FC, useContext } from 'react';
+
+import { Avatar, Badge, List, ListItem, ListItemAvatar, ListItemIcon, ListItemText } from '@mui/material';
+
+import LockOpenIcon from '@mui/icons-material/LockOpen';
+import LockIcon from '@mui/icons-material/Lock';
+import WifiIcon from '@mui/icons-material/Wifi';
+
+import { MessageBox } from '../../components';
+
+import { WiFiEncryptionType, WiFiNetwork, WiFiNetworkList } from '../../types';
+
+import { WiFiConnectionContext } from './WiFiConnectionContext';
+
+interface WiFiNetworkSelectorProps {
+ networkList: WiFiNetworkList;
+}
+
+export const isNetworkOpen = ({ encryption_type }: WiFiNetwork) =>
+ encryption_type === WiFiEncryptionType.WIFI_AUTH_OPEN;
+
+export const networkSecurityMode = ({ encryption_type }: WiFiNetwork) => {
+ switch (encryption_type) {
+ case WiFiEncryptionType.WIFI_AUTH_WEP:
+ return 'WEP';
+ case WiFiEncryptionType.WIFI_AUTH_WPA_PSK:
+ return 'WPA';
+ case WiFiEncryptionType.WIFI_AUTH_WPA2_PSK:
+ return 'WPA2';
+ case WiFiEncryptionType.WIFI_AUTH_WPA_WPA2_PSK:
+ return 'WPA/WPA2';
+ case WiFiEncryptionType.WIFI_AUTH_WPA2_ENTERPRISE:
+ return 'WPA2 Enterprise';
+ case WiFiEncryptionType.WIFI_AUTH_OPEN:
+ return 'None';
+ default:
+ return 'Unknown';
+ }
+};
+
+const WiFiNetworkSelector: FC = ({ networkList }) => {
+ const wifiConnectionContext = useContext(WiFiConnectionContext);
+
+ const renderNetwork = (network: WiFiNetwork) => {
+ return (
+ wifiConnectionContext.selectNetwork(network)}>
+
+ {isNetworkOpen(network) ? : }
+
+
+
+
+
+
+
+
+ );
+ };
+
+ if (networkList.networks.length === 0) {
+ return ;
+ }
+
+ return {networkList.networks.map(renderNetwork)}
;
+};
+
+export default WiFiNetworkSelector;
diff --git a/interface/src/framework/ntp/NTPSettingsForm.tsx b/interface/src/framework/ntp/NTPSettingsForm.tsx
new file mode 100644
index 000000000..557239625
--- /dev/null
+++ b/interface/src/framework/ntp/NTPSettingsForm.tsx
@@ -0,0 +1,101 @@
+import { FC, useState } from 'react';
+import { ValidateFieldsError } from 'async-validator';
+
+import { Button, Checkbox, MenuItem } from '@mui/material';
+import SaveIcon from '@mui/icons-material/Save';
+
+import { validate } from '../../validators';
+import { BlockFormControlLabel, ButtonRow, FormLoader, SectionContent, ValidatedTextField } from '../../components';
+import { NTPSettings } from '../../types';
+import { updateValue, useRest } from '../../utils';
+import * as NTPApi from '../../api/ntp';
+import { selectedTimeZone, timeZoneSelectItems, TIME_ZONES } from './TZ';
+import { NTP_SETTINGS_VALIDATOR } from '../../validators/ntp';
+
+const NTPSettingsForm: FC = () => {
+ const { loadData, saving, data, setData, saveData, errorMessage } = useRest({
+ read: NTPApi.readNTPSettings,
+ update: NTPApi.updateNTPSettings
+ });
+
+ const updateFormValue = updateValue(setData);
+
+ const [fieldErrors, setFieldErrors] = useState();
+
+ const content = () => {
+ if (!data) {
+ return ;
+ }
+
+ const validateAndSubmit = async () => {
+ try {
+ setFieldErrors(undefined);
+ await validate(NTP_SETTINGS_VALIDATOR, data);
+ saveData();
+ } catch (errors: any) {
+ setFieldErrors(errors);
+ }
+ };
+
+ const changeTimeZone = (event: React.ChangeEvent) => {
+ setData({
+ ...data,
+ tz_label: event.target.value,
+ tz_format: TIME_ZONES[event.target.value]
+ });
+ };
+
+ return (
+ <>
+ }
+ label="Enable NTP"
+ />
+
+
+
+ {timeZoneSelectItems()}
+
+
+ }
+ disabled={saving}
+ variant="outlined"
+ color="primary"
+ type="submit"
+ onClick={validateAndSubmit}
+ >
+ Save
+
+
+ >
+ );
+ };
+
+ return (
+
+ {content()}
+
+ );
+};
+
+export default NTPSettingsForm;
diff --git a/interface/src/framework/ntp/NTPStatusForm.tsx b/interface/src/framework/ntp/NTPStatusForm.tsx
new file mode 100644
index 000000000..51182af5f
--- /dev/null
+++ b/interface/src/framework/ntp/NTPStatusForm.tsx
@@ -0,0 +1,213 @@
+import { FC, useContext, useState } from 'react';
+import { useSnackbar } from 'notistack';
+
+import {
+ Avatar,
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Divider,
+ List,
+ ListItem,
+ ListItemAvatar,
+ ListItemText,
+ TextField,
+ Theme,
+ useTheme
+} from '@mui/material';
+import RefreshIcon from '@mui/icons-material/Refresh';
+import AccessTimeIcon from '@mui/icons-material/AccessTime';
+import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
+import UpdateIcon from '@mui/icons-material/Update';
+import DnsIcon from '@mui/icons-material/Dns';
+import CancelIcon from '@mui/icons-material/Cancel';
+
+import * as NTPApi from '../../api/ntp';
+import { NTPStatus, NTPSyncStatus } from '../../types';
+import { ButtonRow, FormLoader, SectionContent } from '../../components';
+import { extractErrorMessage, formatDateTime, formatLocalDateTime, useRest } from '../../utils';
+import { AuthenticatedContext } from '../../contexts/authentication';
+
+export const isNtpActive = ({ status }: NTPStatus) => status === NTPSyncStatus.NTP_ACTIVE;
+export const isNtpEnabled = ({ status }: NTPStatus) => status !== NTPSyncStatus.NTP_DISABLED;
+
+export const ntpStatusHighlight = ({ status }: NTPStatus, theme: Theme) => {
+ switch (status) {
+ case NTPSyncStatus.NTP_DISABLED:
+ return theme.palette.info.main;
+ case NTPSyncStatus.NTP_INACTIVE:
+ return theme.palette.error.main;
+ case NTPSyncStatus.NTP_ACTIVE:
+ return theme.palette.success.main;
+ default:
+ return theme.palette.error.main;
+ }
+};
+
+export const ntpStatus = ({ status }: NTPStatus) => {
+ switch (status) {
+ case NTPSyncStatus.NTP_DISABLED:
+ return 'Disabled';
+ case NTPSyncStatus.NTP_INACTIVE:
+ return 'Inactive';
+ case NTPSyncStatus.NTP_ACTIVE:
+ return 'Active';
+ default:
+ return 'Unknown';
+ }
+};
+
+const NTPStatusForm: FC = () => {
+ const { loadData, data, errorMessage } = useRest({ read: NTPApi.readNTPStatus });
+ const [localTime, setLocalTime] = useState('');
+ const [settingTime, setSettingTime] = useState(false);
+ const [processing, setProcessing] = useState(false);
+ const { enqueueSnackbar } = useSnackbar();
+ const { me } = useContext(AuthenticatedContext);
+
+ const updateLocalTime = (event: React.ChangeEvent) => setLocalTime(event.target.value);
+
+ const openSetTime = () => {
+ setLocalTime(formatLocalDateTime(new Date()));
+ setSettingTime(true);
+ };
+
+ const theme = useTheme();
+
+ const configureTime = async () => {
+ setProcessing(true);
+ try {
+ await NTPApi.updateTime({
+ local_time: formatLocalDateTime(new Date(localTime))
+ });
+ enqueueSnackbar('Time set', { variant: 'success' });
+ setSettingTime(false);
+ loadData();
+ } catch (error: unknown) {
+ enqueueSnackbar(extractErrorMessage(error, 'Problem updating time'), { variant: 'error' });
+ } finally {
+ setProcessing(false);
+ }
+ };
+
+ const renderSetTimeDialog = () => {
+ return (
+
+ );
+ };
+
+ const content = () => {
+ if (!data) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+ {isNtpEnabled(data) && (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } variant="outlined" color="secondary" onClick={loadData}>
+ Refresh
+
+
+
+ {me.admin && data && !isNtpActive(data) && (
+
+
+ }>
+ Set Time
+
+
+
+ )}
+
+ {renderSetTimeDialog()}
+ >
+ );
+ };
+
+ return (
+
+ {content()}
+
+ );
+};
+
+export default NTPStatusForm;
diff --git a/interface/src/framework/ntp/NetworkTime.tsx b/interface/src/framework/ntp/NetworkTime.tsx
new file mode 100644
index 000000000..309edff75
--- /dev/null
+++ b/interface/src/framework/ntp/NetworkTime.tsx
@@ -0,0 +1,40 @@
+import React, { FC, useContext } from 'react';
+import { Navigate, Route, Routes } from 'react-router-dom';
+
+import { Tab } from '@mui/material';
+
+import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
+import { AuthenticatedContext } from '../../contexts/authentication';
+
+import NTPStatusForm from './NTPStatusForm';
+import NTPSettingsForm from './NTPSettingsForm';
+
+const NetworkTime: FC = () => {
+ useLayoutTitle('Network Time');
+
+ const authenticatedContext = useContext(AuthenticatedContext);
+ const { routerTab } = useRouterTab();
+
+ return (
+ <>
+
+
+
+
+
+ } />
+
+
+
+ }
+ />
+ } />
+
+ >
+ );
+};
+
+export default NetworkTime;
diff --git a/interface/src/ntp/TZ.tsx b/interface/src/framework/ntp/TZ.tsx
similarity index 99%
rename from interface/src/ntp/TZ.tsx
rename to interface/src/framework/ntp/TZ.tsx
index 099300d66..967475ea0 100644
--- a/interface/src/ntp/TZ.tsx
+++ b/interface/src/framework/ntp/TZ.tsx
@@ -1,4 +1,4 @@
-import MenuItem from '@material-ui/core/MenuItem';
+import { MenuItem } from '@mui/material';
type TimeZones = {
[name: string]: string;
diff --git a/interface/src/framework/security/GenerateToken.tsx b/interface/src/framework/security/GenerateToken.tsx
new file mode 100644
index 000000000..75296c946
--- /dev/null
+++ b/interface/src/framework/security/GenerateToken.tsx
@@ -0,0 +1,79 @@
+import { FC, useCallback, useState, useEffect } from 'react';
+import {
+ Dialog,
+ DialogTitle,
+ DialogContent,
+ DialogActions,
+ Box,
+ LinearProgress,
+ Typography,
+ TextField,
+ Button
+} from '@mui/material';
+
+import CloseIcon from '@mui/icons-material/Close';
+
+import { extractErrorMessage } from '../../utils';
+import { useSnackbar } from 'notistack';
+import { MessageBox } from '../../components';
+import * as SecurityApi from '../../api/security';
+import { Token } from '../../types';
+
+interface GenerateTokenProps {
+ username?: string;
+ onClose: () => void;
+}
+
+const GenerateToken: FC = ({ username, onClose }) => {
+ const [token, setToken] = useState();
+ const open = !!username;
+
+ const { enqueueSnackbar } = useSnackbar();
+
+ const getToken = useCallback(async () => {
+ try {
+ setToken((await SecurityApi.generateToken(username)).data);
+ } catch (error: unknown) {
+ enqueueSnackbar(extractErrorMessage(error, 'Problem generating token'), { variant: 'error' });
+ }
+ }, [username, enqueueSnackbar]);
+
+ useEffect(() => {
+ if (open) {
+ getToken();
+ }
+ }, [open, getToken]);
+
+ return (
+
+ );
+};
+
+export default GenerateToken;
diff --git a/interface/src/framework/security/ManageUsersForm.tsx b/interface/src/framework/security/ManageUsersForm.tsx
new file mode 100644
index 000000000..8d14d1087
--- /dev/null
+++ b/interface/src/framework/security/ManageUsersForm.tsx
@@ -0,0 +1,218 @@
+import { FC, useContext, useState } from 'react';
+
+import { Button, IconButton, Box } from '@mui/material';
+import SaveIcon from '@mui/icons-material/Save';
+import DeleteIcon from '@mui/icons-material/Delete';
+import PersonAddIcon from '@mui/icons-material/PersonAdd';
+import EditIcon from '@mui/icons-material/Edit';
+import CheckIcon from '@mui/icons-material/Check';
+import CloseIcon from '@mui/icons-material/Close';
+import VpnKeyIcon from '@mui/icons-material/VpnKey';
+
+import { Table } from '@table-library/react-table-library/table';
+import { useTheme } from '@table-library/react-table-library/theme';
+import { Header, HeaderRow, HeaderCell, Body, Row, Cell } from '@table-library/react-table-library/table';
+
+import * as SecurityApi from '../../api/security';
+import { SecuritySettings, User } from '../../types';
+import { ButtonRow, FormLoader, MessageBox, SectionContent } from '../../components';
+import { createUserValidator } from '../../validators';
+import { useRest } from '../../utils';
+import { AuthenticatedContext } from '../../contexts/authentication';
+
+import GenerateToken from './GenerateToken';
+import UserForm from './UserForm';
+
+const ManageUsersForm: FC = () => {
+ const { loadData, saving, data, setData, saveData, errorMessage } = useRest({
+ read: SecurityApi.readSecuritySettings,
+ update: SecurityApi.updateSecuritySettings
+ });
+
+ const [user, setUser] = useState();
+ const [creating, setCreating] = useState(false);
+ const [generatingToken, setGeneratingToken] = useState();
+ const authenticatedContext = useContext(AuthenticatedContext);
+
+ const table_theme = useTheme({
+ BaseRow: `
+ font-size: 14px;
+ color: white;
+ padding-left: 8px;
+ `,
+ HeaderRow: `
+ text-transform: uppercase;
+ background-color: black;
+ color: #90CAF9;
+ font-weight: 500;
+ border-bottom: 1px solid #e0e0e0;
+ `,
+ Row: `
+ &:nth-of-type(odd) {
+ background-color: #303030;
+ }
+ &:nth-of-type(even) {
+ background-color: #1e1e1e;
+ }
+ border-top: 1px solid #565656;
+ border-bottom: 1px solid #565656;
+ position: relative;
+ z-index: 1;
+ &:not(:last-of-type) {
+ margin-bottom: -1px;
+ }
+ &:not(:first-of-type) {
+ margin-top: -1px;
+ }
+ &:hover {
+ color: white;
+ }
+ `,
+ BaseCell: `
+ border-top: 1px solid transparent;
+ border-right: 1px solid transparent;
+ border-bottom: 1px solid transparent;
+ `
+ });
+
+ const content = () => {
+ if (!data) {
+ return ;
+ }
+
+ const noAdminConfigured = () => !data.users.find((u) => u.admin);
+
+ const removeUser = (toRemove: User) => {
+ const users = data.users.filter((u) => u.username !== toRemove.username);
+ setData({ ...data, users });
+ };
+
+ const createUser = () => {
+ setCreating(true);
+ setUser({
+ username: '',
+ password: '',
+ admin: true
+ });
+ };
+
+ const editUser = (toEdit: User) => {
+ setCreating(false);
+ setUser({ ...toEdit });
+ };
+
+ const cancelEditingUser = () => {
+ setUser(undefined);
+ };
+
+ const doneEditingUser = () => {
+ if (user) {
+ const users = [...data.users.filter((u) => u.username !== user.username), user];
+ setData({ ...data, users });
+ setUser(undefined);
+ }
+ };
+
+ const closeGenerateToken = () => {
+ setGeneratingToken(undefined);
+ };
+
+ const generateToken = (username: string) => {
+ setGeneratingToken(username);
+ };
+
+ const onSubmit = async () => {
+ await saveData();
+ authenticatedContext.refresh();
+ };
+
+ const user_table = data.users.map((u) => ({ ...u, id: u.username }));
+
+ return (
+ <>
+
+ {(tableList: any) => (
+ <>
+
+
+ USERNAME
+ IS ADMIN
+
+
+
+
+ {tableList.map((u: any) => (
+
+ | {u.username} |
+ {u.admin ? : } |
+
+ generateToken(u.username)}
+ >
+
+
+ removeUser(u)}>
+
+
+ editUser(u)}>
+
+
+ |
+
+ ))}
+
+ >
+ )}
+
+
+ {noAdminConfigured() && (
+
+ )}
+
+
+
+ }
+ disabled={saving || noAdminConfigured()}
+ variant="outlined"
+ color="primary"
+ type="submit"
+ onClick={onSubmit}
+ >
+ Save
+
+
+
+
+
+ } variant="outlined" color="secondary" onClick={createUser}>
+ Add
+
+
+
+
+
+
+
+ >
+ );
+ };
+
+ return (
+
+ {content()}
+
+ );
+};
+
+export default ManageUsersForm;
diff --git a/interface/src/framework/security/Security.tsx b/interface/src/framework/security/Security.tsx
new file mode 100644
index 000000000..f4f892407
--- /dev/null
+++ b/interface/src/framework/security/Security.tsx
@@ -0,0 +1,31 @@
+import { FC } from 'react';
+import { Navigate, Routes, Route } from 'react-router-dom';
+
+import { Tab } from '@mui/material';
+
+import { RouterTabs, useRouterTab, useLayoutTitle } from '../../components';
+
+import SecuritySettingsForm from './SecuritySettingsForm';
+import ManageUsersForm from './ManageUsersForm';
+
+const Security: FC = () => {
+ useLayoutTitle('Security');
+
+ const { routerTab } = useRouterTab();
+
+ return (
+ <>
+
+
+
+
+
+ } />
+ } />
+ } />
+
+ >
+ );
+};
+
+export default Security;
diff --git a/interface/src/framework/security/SecuritySettingsForm.tsx b/interface/src/framework/security/SecuritySettingsForm.tsx
new file mode 100644
index 000000000..0a300a3a2
--- /dev/null
+++ b/interface/src/framework/security/SecuritySettingsForm.tsx
@@ -0,0 +1,80 @@
+import { FC, useContext, useState } from 'react';
+import { ValidateFieldsError } from 'async-validator';
+
+import { Button } from '@mui/material';
+import SaveIcon from '@mui/icons-material/Save';
+
+import * as SecurityApi from '../../api/security';
+import { SecuritySettings } from '../../types';
+import { ButtonRow, FormLoader, MessageBox, SectionContent, ValidatedPasswordField } from '../../components';
+import { SECURITY_SETTINGS_VALIDATOR, validate } from '../../validators';
+import { updateValue, useRest } from '../../utils';
+import { AuthenticatedContext } from '../../contexts/authentication';
+
+const SecuritySettingsForm: FC = () => {
+ const [fieldErrors, setFieldErrors] = useState();
+ const { loadData, saving, data, setData, saveData, errorMessage } = useRest({
+ read: SecurityApi.readSecuritySettings,
+ update: SecurityApi.updateSecuritySettings
+ });
+
+ const authenticatedContext = useContext(AuthenticatedContext);
+ const updateFormValue = updateValue(setData);
+
+ const content = () => {
+ if (!data) {
+ return ;
+ }
+
+ const validateAndSubmit = async () => {
+ try {
+ setFieldErrors(undefined);
+ await validate(SECURITY_SETTINGS_VALIDATOR, data);
+ await saveData();
+ await authenticatedContext.refresh();
+ } catch (errors: any) {
+ setFieldErrors(errors);
+ }
+ };
+
+ return (
+ <>
+
+
+
+ }
+ disabled={saving}
+ variant="outlined"
+ color="primary"
+ type="submit"
+ onClick={validateAndSubmit}
+ >
+ Save
+
+
+ >
+ );
+ };
+
+ return (
+
+ {content()}
+
+ );
+};
+
+export default SecuritySettingsForm;
diff --git a/interface/src/framework/security/UserForm.tsx b/interface/src/framework/security/UserForm.tsx
new file mode 100644
index 000000000..4a82f08a9
--- /dev/null
+++ b/interface/src/framework/security/UserForm.tsx
@@ -0,0 +1,100 @@
+import { FC, useState, useEffect } from 'react';
+import Schema, { ValidateFieldsError } from 'async-validator';
+
+import CancelIcon from '@mui/icons-material/Cancel';
+import PersonAddIcon from '@mui/icons-material/PersonAdd';
+
+import { Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
+
+import { BlockFormControlLabel, ValidatedPasswordField, ValidatedTextField } from '../../components';
+import { User } from '../../types';
+import { updateValue } from '../../utils';
+import { validate } from '../../validators';
+
+interface UserFormProps {
+ creating: boolean;
+ validator: Schema;
+
+ user?: User;
+ setUser: React.Dispatch>;
+
+ onDoneEditing: () => void;
+ onCancelEditing: () => void;
+}
+
+const UserForm: FC = ({ creating, validator, user, setUser, onDoneEditing, onCancelEditing }) => {
+ const updateFormValue = updateValue(setUser);
+ const [fieldErrors, setFieldErrors] = useState();
+ const open = !!user;
+
+ useEffect(() => {
+ if (open) {
+ setFieldErrors(undefined);
+ }
+ }, [open]);
+
+ const validateAndDone = async () => {
+ if (user) {
+ try {
+ setFieldErrors(undefined);
+ await validate(validator, user);
+ onDoneEditing();
+ } catch (errors: any) {
+ setFieldErrors(errors);
+ }
+ }
+ };
+
+ return (
+
+ );
+};
+
+export default UserForm;
diff --git a/interface/src/framework/system/GeneralFileUpload.tsx b/interface/src/framework/system/GeneralFileUpload.tsx
new file mode 100644
index 000000000..8f084b69e
--- /dev/null
+++ b/interface/src/framework/system/GeneralFileUpload.tsx
@@ -0,0 +1,28 @@
+import { AxiosPromise } from 'axios';
+import { FC } from 'react';
+
+import { FileUploadConfig } from '../../api/endpoints';
+import { MessageBox, SingleUpload, useFileUpload } from '../../components';
+
+interface UploadFileProps {
+ uploadGeneralFile: (file: File, config?: FileUploadConfig) => AxiosPromise;
+}
+
+const GeneralFileUpload: FC = ({ uploadGeneralFile }) => {
+ const [uploadFile, cancelUpload, uploading, uploadProgress] = useFileUpload({ upload: uploadGeneralFile });
+
+ return (
+ <>
+ {!uploading && (
+
+ )}
+
+ >
+ );
+};
+
+export default GeneralFileUpload;
diff --git a/interface/src/framework/system/OTASettingsForm.tsx b/interface/src/framework/system/OTASettingsForm.tsx
new file mode 100644
index 000000000..cec00f8d2
--- /dev/null
+++ b/interface/src/framework/system/OTASettingsForm.tsx
@@ -0,0 +1,97 @@
+import { FC, useState } from 'react';
+
+import { Button, Checkbox } from '@mui/material';
+import SaveIcon from '@mui/icons-material/Save';
+
+import * as SystemApi from '../../api/system';
+import {
+ BlockFormControlLabel,
+ ButtonRow,
+ FormLoader,
+ SectionContent,
+ ValidatedPasswordField,
+ ValidatedTextField
+} from '../../components';
+import { OTASettings } from '../../types';
+import { numberValue, updateValue, useRest } from '../../utils';
+
+import { ValidateFieldsError } from 'async-validator';
+import { validate } from '../../validators';
+import { OTA_SETTINGS_VALIDATOR } from '../../validators/system';
+
+const OTASettingsForm: FC = () => {
+ const { loadData, saving, data, setData, saveData, errorMessage } = useRest({
+ read: SystemApi.readOTASettings,
+ update: SystemApi.updateOTASettings
+ });
+
+ const updateFormValue = updateValue(setData);
+
+ const [fieldErrors, setFieldErrors] = useState();
+
+ const content = () => {
+ if (!data) {
+ return ;
+ }
+
+ const validateAndSubmit = async () => {
+ try {
+ setFieldErrors(undefined);
+ await validate(OTA_SETTINGS_VALIDATOR, data);
+ saveData();
+ } catch (errors: any) {
+ setFieldErrors(errors);
+ }
+ };
+
+ return (
+ <>
+ }
+ label="Enable OTA Updates"
+ />
+
+
+
+ }
+ disabled={saving}
+ variant="outlined"
+ color="primary"
+ type="submit"
+ onClick={validateAndSubmit}
+ >
+ Save
+
+
+ >
+ );
+ };
+
+ return (
+
+ {content()}
+
+ );
+};
+
+export default OTASettingsForm;
diff --git a/interface/src/framework/system/RestartMonitor.tsx b/interface/src/framework/system/RestartMonitor.tsx
new file mode 100644
index 000000000..3e832f302
--- /dev/null
+++ b/interface/src/framework/system/RestartMonitor.tsx
@@ -0,0 +1,43 @@
+import { useEffect } from 'react';
+import { FC, useRef, useState } from 'react';
+
+import * as SystemApi from '../../api/system';
+import { FormLoader } from '../../components';
+
+const RESTART_TIMEOUT = 2 * 60 * 1000;
+const POLL_TIMEOUT = 2000;
+const POLL_INTERVAL = 5000;
+
+const RestartMonitor: FC = () => {
+ const [failed, setFailed] = useState(false);
+ const [timeoutId, setTimeoutId] = useState();
+
+ const timeoutAt = useRef(new Date().getTime() + RESTART_TIMEOUT);
+ const poll = useRef(async () => {
+ try {
+ await SystemApi.readSystemStatus(POLL_TIMEOUT);
+ document.location.href = '/fileUpdated';
+ } catch (error: unknown) {
+ if (new Date().getTime() < timeoutAt.current) {
+ setTimeoutId(setTimeout(poll.current, POLL_INTERVAL));
+ } else {
+ setFailed(true);
+ }
+ }
+ });
+
+ useEffect(() => {
+ poll.current();
+ }, []);
+
+ useEffect(() => () => timeoutId && clearTimeout(timeoutId), [timeoutId]);
+
+ return (
+
+ );
+};
+
+export default RestartMonitor;
diff --git a/interface/src/framework/system/System.tsx b/interface/src/framework/system/System.tsx
new file mode 100644
index 000000000..07cf8296d
--- /dev/null
+++ b/interface/src/framework/system/System.tsx
@@ -0,0 +1,60 @@
+import React, { FC, useContext } from 'react';
+import { Navigate, Routes, Route } from 'react-router-dom';
+
+import { Tab } from '@mui/material';
+
+import { useRouterTab, RouterTabs, useLayoutTitle, RequireAdmin } from '../../components';
+import { AuthenticatedContext } from '../../contexts/authentication';
+import { FeaturesContext } from '../../contexts/features';
+import UploadFileForm from './UploadFileForm';
+import SystemStatusForm from './SystemStatusForm';
+import OTASettingsForm from './OTASettingsForm';
+
+import SystemLog from './SystemLog';
+
+const System: FC = () => {
+ useLayoutTitle('System');
+
+ const { me } = useContext(AuthenticatedContext);
+ const { features } = useContext(FeaturesContext);
+ const { routerTab } = useRouterTab();
+
+ return (
+ <>
+
+
+
+
+ {features.ota && }
+ {features.upload_firmware && }
+
+
+ } />
+ } />
+ {features.ota && (
+
+
+
+ }
+ />
+ )}
+ {features.upload_firmware && (
+
+
+
+ }
+ />
+ )}
+ } />
+
+ >
+ );
+};
+
+export default System;
diff --git a/interface/src/framework/system/SystemLog.tsx b/interface/src/framework/system/SystemLog.tsx
new file mode 100644
index 000000000..9d5413aa6
--- /dev/null
+++ b/interface/src/framework/system/SystemLog.tsx
@@ -0,0 +1,282 @@
+import { FC, useState, useEffect, useCallback, useLayoutEffect } from 'react';
+
+import { Box, styled, Button, Checkbox, MenuItem, Grid, Slider, FormLabel } from '@mui/material';
+
+import * as SystemApi from '../../api/system';
+import { addAccessTokenParameter } from '../../api/authentication';
+
+import { SectionContent, FormLoader, BlockFormControlLabel, ValidatedTextField } from '../../components';
+
+import { LogSettings, LogEntry, LogEntries, LogLevel } from '../../types';
+import { updateValue, useRest, extractErrorMessage } from '../../utils';
+
+import DownloadIcon from '@mui/icons-material/GetApp';
+
+import { useSnackbar } from 'notistack';
+
+import { EVENT_SOURCE_ROOT } from '../../api/endpoints';
+export const LOG_EVENTSOURCE_URL = EVENT_SOURCE_ROOT + 'log';
+
+const useWindowSize = () => {
+ const [size, setSize] = useState([0, 0]);
+ useLayoutEffect(() => {
+ function updateSize() {
+ setSize([window.innerWidth, window.innerHeight]);
+ }
+ window.addEventListener('resize', updateSize);
+ updateSize();
+ return () => window.removeEventListener('resize', updateSize);
+ }, []);
+ return size;
+};
+
+const LogEntryLine = styled('div')(({ theme }) => ({
+ color: '#bbbbbb',
+ fontFamily: 'monospace',
+ fontSize: '14px',
+ letterSpacing: 'normal',
+ whiteSpace: 'nowrap'
+}));
+
+const topOffset = () => document.getElementById('log-window')?.getBoundingClientRect().bottom || 0;
+const leftOffset = () => document.getElementById('log-window')?.getBoundingClientRect().left || 0;
+
+const levelLabel = (level: LogLevel) => {
+ switch (level) {
+ case LogLevel.ERROR:
+ return 'ERROR';
+ case LogLevel.WARNING:
+ return 'WARNING';
+ case LogLevel.NOTICE:
+ return 'NOTICE';
+ case LogLevel.INFO:
+ return 'INFO';
+ case LogLevel.DEBUG:
+ return 'DEBUG';
+ case LogLevel.TRACE:
+ return 'TRACE';
+ default:
+ return '';
+ }
+};
+
+const SystemLog: FC = () => {
+ useWindowSize();
+
+ const { loadData, data, setData } = useRest({
+ read: SystemApi.readLogSettings
+ });
+
+ const [errorMessage, setErrorMessage] = useState();
+ const [reconnectTimeout, setReconnectTimeout] = useState();
+ const [logEntries, setLogEntries] = useState({ events: [] });
+ const [lastIndex, setLastIndex] = useState(0);
+
+ const paddedLevelLabel = (level: LogLevel) => {
+ const label = levelLabel(level);
+ return data?.compact ? ' ' + label[0] : label.padStart(8, '\xa0');
+ };
+
+ const paddedNameLabel = (name: string) => {
+ const label = '[' + name + ']';
+ return data?.compact ? label : label.padEnd(12, '\xa0');
+ };
+
+ const paddedIDLabel = (id: number) => {
+ const label = id + ':';
+ return data?.compact ? label : label.padEnd(7, '\xa0');
+ };
+
+ const updateFormValue = updateValue(setData);
+
+ const { enqueueSnackbar } = useSnackbar();
+
+ const reloadPage = () => {
+ window.location.reload();
+ };
+
+ const sendSettings = async (new_max_messages: number, new_level: number) => {
+ if (data) {
+ try {
+ const response = await SystemApi.updateLogSettings({
+ level: new_level,
+ max_messages: new_max_messages,
+ compact: data.compact
+ });
+ if (response.status !== 200) {
+ enqueueSnackbar('Problem applying log settings', { variant: 'error' });
+ }
+ } catch (error: unknown) {
+ enqueueSnackbar(extractErrorMessage(error, 'Problem applying log settings'), { variant: 'error' });
+ }
+ }
+ };
+
+ const changeLevel = (event: React.ChangeEvent) => {
+ if (data) {
+ setData({
+ ...data,
+ level: parseInt(event.target.value)
+ });
+ sendSettings(data.max_messages, parseInt(event.target.value));
+ }
+ };
+
+ const changeMaxMessages = (event: Event, value: number | number[]) => {
+ if (data) {
+ setData({
+ ...data,
+ max_messages: value as number
+ });
+ }
+ };
+
+ const onDownload = () => {
+ let result = '';
+ for (let i of logEntries.events) {
+ result += i.t + ' ' + levelLabel(i.l) + ' ' + i.i + ': [' + i.n + '] ' + i.m + '\n';
+ }
+ const a = document.createElement('a');
+ a.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(result));
+ a.setAttribute('download', 'log.txt');
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ };
+
+ const onMessage = (event: MessageEvent) => {
+ const rawData = event.data;
+ if (typeof rawData === 'string' || rawData instanceof String) {
+ const logentry = JSON.parse(rawData as string) as LogEntry;
+ if (logentry.i > lastIndex) {
+ setLastIndex(logentry.i);
+ setLogEntries((old) => ({ events: [...old.events, logentry] }));
+ }
+ }
+ };
+
+ const fetchLog = useCallback(async () => {
+ try {
+ setLogEntries((await SystemApi.readLogEntries()).data);
+ } catch (error: unknown) {
+ setErrorMessage(extractErrorMessage(error, 'Failed to fetch log'));
+ }
+ }, []);
+
+ useEffect(() => {
+ fetchLog();
+ }, [fetchLog]);
+
+ useEffect(() => {
+ const es = new EventSource(addAccessTokenParameter(LOG_EVENTSOURCE_URL));
+ es.onmessage = onMessage;
+ es.onerror = () => {
+ if (reconnectTimeout) {
+ es.close();
+ setReconnectTimeout(setTimeout(reloadPage, 1000));
+ }
+ };
+
+ return () => {
+ es.close();
+ if (reconnectTimeout) {
+ clearTimeout(reconnectTimeout);
+ }
+ };
+ // eslint-disable-next-line
+ }, [reconnectTimeout]);
+
+ const content = () => {
+ if (!data) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+
+
+
+
+ Buffer size
+ sendSettings(data.max_messages, data.level)}
+ />
+
+
+ }
+ label="Compact"
+ />
+
+
+ } variant="outlined" color="secondary" onClick={onDownload}>
+ Export
+
+
+
+ leftOffset(),
+ top: () => topOffset(),
+ p: 1
+ }}
+ >
+ {logEntries &&
+ logEntries.events.map((e) => (
+
+ {e.t}
+ {data.compact && {paddedLevelLabel(e.l)} }
+ {!data.compact && {paddedLevelLabel(e.l)} }
+ {paddedIDLabel(e.i)}
+ {paddedNameLabel(e.n)}
+ {e.m}
+
+ ))}
+
+ >
+ );
+ };
+
+ return (
+
+ {content()}
+
+ );
+};
+
+export default SystemLog;
diff --git a/interface/src/framework/system/SystemStatusForm.tsx b/interface/src/framework/system/SystemStatusForm.tsx
new file mode 100644
index 000000000..52c1a3a4b
--- /dev/null
+++ b/interface/src/framework/system/SystemStatusForm.tsx
@@ -0,0 +1,379 @@
+import { FC, useContext, useState, useEffect } from 'react';
+import { useSnackbar } from 'notistack';
+import {
+ Avatar,
+ Box,
+ Button,
+ Dialog,
+ DialogActions,
+ DialogContent,
+ DialogTitle,
+ Divider,
+ List,
+ ListItem,
+ ListItemAvatar,
+ ListItemText,
+ Link,
+ Typography
+} from '@mui/material';
+
+import DevicesIcon from '@mui/icons-material/Devices';
+import ShowChartIcon from '@mui/icons-material/ShowChart';
+import MemoryIcon from '@mui/icons-material/Memory';
+import AppsIcon from '@mui/icons-material/Apps';
+import SdStorageIcon from '@mui/icons-material/SdStorage';
+import FolderIcon from '@mui/icons-material/Folder';
+import RefreshIcon from '@mui/icons-material/Refresh';
+import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
+import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
+import BuildIcon from '@mui/icons-material/Build';
+import TimerIcon from '@mui/icons-material/Timer';
+import CancelIcon from '@mui/icons-material/Cancel';
+
+import { ButtonRow, FormLoader, SectionContent, MessageBox } from '../../components';
+import { EspPlatform, SystemStatus, Version } from '../../types';
+import * as SystemApi from '../../api/system';
+import { extractErrorMessage, useRest } from '../../utils';
+
+import { AuthenticatedContext } from '../../contexts/authentication';
+
+import axios from 'axios';
+
+export const VERSIONCHECK_ENDPOINT = 'https://api.github.com/repos/emsesp/EMS-ESP32/releases/latest';
+export const VERSIONCHECK_DEV_ENDPOINT = 'https://api.github.com/repos/emsesp/EMS-ESP32/releases/tags/latest';
+export const uploadURL = window.location.origin + '/system/upload';
+
+function formatNumber(num: number) {
+ return new Intl.NumberFormat().format(num);
+}
+
+const SystemStatusForm: FC = () => {
+ const { loadData, data, errorMessage } = useRest({ read: SystemApi.readSystemStatus });
+
+ const { me } = useContext(AuthenticatedContext);
+ const [confirmRestart, setConfirmRestart] = useState(false);
+ const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
+ const [processing, setProcessing] = useState(false);
+ const { enqueueSnackbar } = useSnackbar();
+ const [showingVersion, setShowingVersion] = useState(false);
+ const [latestVersion, setLatestVersion] = useState();
+ const [latestDevVersion, setLatestDevVersion] = useState();
+
+ useEffect(() => {
+ axios.get(VERSIONCHECK_ENDPOINT).then((response) => {
+ setLatestVersion({
+ version: response.data.name,
+ url: response.data.assets[1].browser_download_url,
+ changelog: response.data.html_url
+ });
+ });
+ axios.get(VERSIONCHECK_DEV_ENDPOINT).then((response) => {
+ setLatestDevVersion({
+ version: response.data.name.split(/\s+/).splice(-1),
+ url: response.data.assets[1].browser_download_url,
+ changelog: response.data.assets[0].browser_download_url
+ });
+ });
+ }, []);
+
+ const restart = async () => {
+ setProcessing(true);
+ try {
+ await SystemApi.restart();
+ enqueueSnackbar('EMS-ESP is restarting...', { variant: 'info' });
+ } catch (error: unknown) {
+ enqueueSnackbar(extractErrorMessage(error, 'Problem restarting device'), { variant: 'error' });
+ } finally {
+ setConfirmRestart(false);
+ setProcessing(false);
+ }
+ };
+
+ const renderRestartDialog = () => (
+
+ );
+
+ const renderVersionDialog = () => {
+ return (
+
+ );
+ };
+
+ const factoryReset = async () => {
+ setProcessing(true);
+ try {
+ await SystemApi.factoryReset();
+ enqueueSnackbar('Device has been factory reset and will now restart', { variant: 'info' });
+ } catch (error: unknown) {
+ enqueueSnackbar(extractErrorMessage(error, 'Problem factory resetting the device'), { variant: 'error' });
+ } finally {
+ setConfirmFactoryReset(false);
+ setProcessing(false);
+ }
+ };
+
+ const renderFactoryResetDialog = () => (
+
+ );
+
+ const content = () => {
+ if (!data) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+
+
+
+
+
+
+ {latestVersion && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {data.esp_platform === EspPlatform.ESP32 && data.psram_size > 0 && (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ } variant="outlined" color="secondary" onClick={loadData}>
+ Refresh
+
+
+
+ {me.admin && (
+
+
+ }
+ variant="outlined"
+ color="primary"
+ onClick={() => setConfirmRestart(true)}
+ >
+ Restart
+
+ }
+ variant="outlined"
+ onClick={() => setConfirmFactoryReset(true)}
+ color="error"
+ >
+ Factory reset
+
+
+
+ )}
+
+ {renderVersionDialog()}
+ {renderRestartDialog()}
+ {renderFactoryResetDialog()}
+ >
+ );
+ };
+
+ return (
+
+ {content()}
+
+ );
+};
+
+export default SystemStatusForm;
diff --git a/interface/src/framework/system/UploadFileForm.tsx b/interface/src/framework/system/UploadFileForm.tsx
new file mode 100644
index 000000000..29f161310
--- /dev/null
+++ b/interface/src/framework/system/UploadFileForm.tsx
@@ -0,0 +1,26 @@
+import { FC, useRef, useState } from 'react';
+
+import * as SystemApi from '../../api/system';
+import { SectionContent } from '../../components';
+import { FileUploadConfig } from '../../api/endpoints';
+
+import GeneralFileUpload from './GeneralFileUpload';
+import RestartMonitor from './RestartMonitor';
+
+const UploadFileForm: FC = () => {
+ const [restarting, setRestarting] = useState();
+
+ const uploadFile = useRef(async (file: File, config?: FileUploadConfig) => {
+ const response = await SystemApi.uploadFile(file, config);
+ setRestarting(true);
+ return response;
+ });
+
+ return (
+
+ {restarting ? : }
+
+ );
+};
+
+export default UploadFileForm;
diff --git a/interface/src/history.ts b/interface/src/history.ts
deleted file mode 100644
index 2d6284a38..000000000
--- a/interface/src/history.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-import { createBrowserHistory } from 'history';
-
-export default createBrowserHistory({
- /* pass a configuration object here if needed */
-});
diff --git a/interface/src/hooks/index.ts b/interface/src/hooks/index.ts
deleted file mode 100644
index 94897b22a..000000000
--- a/interface/src/hooks/index.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export { default as useRest } from './useRest';
-export { default as useAuthorizedRest } from './useAuthorizedRest';
diff --git a/interface/src/hooks/useAuthorizedRest.ts b/interface/src/hooks/useAuthorizedRest.ts
deleted file mode 100644
index 78fa7ce56..000000000
--- a/interface/src/hooks/useAuthorizedRest.ts
+++ /dev/null
@@ -1,12 +0,0 @@
-import { redirectingAuthorizedFetch } from '../authentication';
-import useRest, { RestRequestOptions } from './useRest';
-
-const useAuthorizedRest = ({
- endpoint
-}: Omit) =>
- useRest({
- endpoint,
- fetchFunction: redirectingAuthorizedFetch
- });
-
-export default useAuthorizedRest;
diff --git a/interface/src/hooks/useRest.ts b/interface/src/hooks/useRest.ts
deleted file mode 100644
index 0d6b30743..000000000
--- a/interface/src/hooks/useRest.ts
+++ /dev/null
@@ -1,79 +0,0 @@
-import { useCallback, useEffect, useState } from 'react';
-import { useSnackbar } from 'notistack';
-
-export interface RestRequestOptions {
- endpoint: string;
- fetchFunction?: typeof fetch;
-}
-
-const useRest = ({
- endpoint,
- fetchFunction = fetch
-}: RestRequestOptions) => {
- const { enqueueSnackbar } = useSnackbar();
-
- const [saving, setSaving] = useState(false);
- const [data, setData] = useState();
- const [errorMessage, setErrorMessage] = useState();
-
- const handleError = useCallback(
- (error: any) => {
- const errorMessage = error.message || 'Unknown error';
- enqueueSnackbar('Problem fetching: ' + errorMessage, {
- variant: 'error'
- });
- setErrorMessage(errorMessage);
- },
- [enqueueSnackbar]
- );
-
- const loadData = useCallback(async () => {
- setData(undefined);
- setErrorMessage(undefined);
- try {
- const response = await fetchFunction(endpoint);
- if (response.status !== 200) {
- throw Error('Invalid status code: ' + response.status);
- }
- setData(await response.json());
- } catch (error) {
- handleError(error);
- }
- }, [handleError, fetchFunction, endpoint]);
-
- const save = useCallback(
- async (data: D) => {
- setSaving(true);
- setErrorMessage(undefined);
- try {
- const response = await fetchFunction(endpoint, {
- method: 'POST',
- body: JSON.stringify(data),
- headers: {
- 'Content-Type': 'application/json'
- }
- });
- if (response.status !== 200) {
- throw Error('Invalid status code: ' + response.status);
- }
- enqueueSnackbar('Update successful.', { variant: 'success' });
- setData(await response.json());
- } catch (error) {
- handleError(error);
- } finally {
- setSaving(false);
- }
- },
- [enqueueSnackbar, handleError, fetchFunction, endpoint]
- );
-
- const saveData = () => data && save(data);
-
- useEffect(() => {
- loadData();
- }, [loadData]);
-
- return { loadData, saveData, saving, setData, data, errorMessage } as const;
-};
-
-export default useRest;
diff --git a/interface/src/index.tsx b/interface/src/index.tsx
index 2abd63e9f..e0f77e141 100644
--- a/interface/src/index.tsx
+++ b/interface/src/index.tsx
@@ -1,14 +1,15 @@
import React from 'react';
-import { render } from 'react-dom';
+import ReactDOM from 'react-dom';
-import history from './history';
-import { Router } from 'react-router';
+import { BrowserRouter } from 'react-router-dom';
import App from './App';
-render(
-
-
- ,
+ReactDOM.render(
+
+
+
+
+ ,
document.getElementById('root')
);
diff --git a/interface/src/mqtt/Mqtt.tsx b/interface/src/mqtt/Mqtt.tsx
deleted file mode 100644
index 6db8dd5e0..000000000
--- a/interface/src/mqtt/Mqtt.tsx
+++ /dev/null
@@ -1,56 +0,0 @@
-import { Component } from 'react';
-import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
-
-import { Tabs, Tab } from '@material-ui/core';
-
-import {
- AuthenticatedContextProps,
- withAuthenticatedContext,
- AuthenticatedRoute
-} from '../authentication';
-import { MenuAppBar } from '../components';
-import MqttStatusController from './MqttStatusController';
-import MqttSettingsController from './MqttSettingsController';
-
-type MqttProps = AuthenticatedContextProps & RouteComponentProps;
-
-class Mqtt extends Component {
- handleTabChange = (path: string) => {
- this.props.history.push(path);
- };
-
- render() {
- const { authenticatedContext } = this.props;
- return (
-
- this.handleTabChange(path)}
- variant="fullWidth"
- >
-
-
-
-
-
-
-
-
-
- );
- }
-}
-
-export default withAuthenticatedContext(Mqtt);
diff --git a/interface/src/mqtt/MqttSettingsController.tsx b/interface/src/mqtt/MqttSettingsController.tsx
deleted file mode 100644
index eeea08d7c..000000000
--- a/interface/src/mqtt/MqttSettingsController.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import React, { Component } from 'react';
-
-import {
- restController,
- RestControllerProps,
- RestFormLoader,
- SectionContent
-} from '../components';
-import { MQTT_SETTINGS_ENDPOINT } from '../api';
-
-import MqttSettingsForm from './MqttSettingsForm';
-import { MqttSettings } from './types';
-
-type MqttSettingsControllerProps = RestControllerProps;
-
-class MqttSettingsController extends Component {
- componentDidMount() {
- this.props.loadData();
- }
-
- render() {
- return (
-
- }
- />
-
- );
- }
-}
-
-export default restController(MQTT_SETTINGS_ENDPOINT, MqttSettingsController);
diff --git a/interface/src/mqtt/MqttSettingsForm.tsx b/interface/src/mqtt/MqttSettingsForm.tsx
deleted file mode 100644
index d789bae06..000000000
--- a/interface/src/mqtt/MqttSettingsForm.tsx
+++ /dev/null
@@ -1,376 +0,0 @@
-import React from 'react';
-import {
- TextValidator,
- ValidatorForm,
- SelectValidator
-} from 'react-material-ui-form-validator';
-
-import { Checkbox, TextField, Typography } from '@material-ui/core';
-import SaveIcon from '@material-ui/icons/Save';
-import MenuItem from '@material-ui/core/MenuItem';
-
-import {
- RestFormProps,
- FormActions,
- FormButton,
- BlockFormControlLabel,
- PasswordValidator
-} from '../components';
-import { isIP, isHostname, or, isPath } from '../validators';
-
-import { MqttSettings } from './types';
-
-type MqttSettingsFormProps = RestFormProps;
-
-class MqttSettingsForm extends React.Component {
- componentDidMount() {
- ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
- ValidatorForm.addValidationRule('isPath', isPath);
- }
-
- render() {
- const { data, handleValueChange, saveData } = this.props;
- return (
-
-
- }
- label="Enable MQTT"
- />
-
-
-
-
-
-
-
-
-
-
-
-
-
- }
- label="Set Clean Session"
- />
-
- }
- label="Use Retain Flag"
- />
-
-
- Formatting
-
-
-
-
-
-
- }
- label="Publish command output to a 'response' topic"
- />
-
- }
- label="Use Home Assistant MQTT Discovery"
- />
- {data.ha_enabled && (
-
-
-
-
-
- )}
-
-
- Publish Intervals
-
-
-
-
-
-
-
-
- }
- variant="contained"
- color="primary"
- type="submit"
- >
- Save
-
-
-
- );
- }
-}
-
-export default MqttSettingsForm;
diff --git a/interface/src/mqtt/MqttStatus.ts b/interface/src/mqtt/MqttStatus.ts
deleted file mode 100644
index c898fddbd..000000000
--- a/interface/src/mqtt/MqttStatus.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-import { Theme } from '@material-ui/core';
-import { MqttStatus, MqttDisconnectReason } from './types';
-
-export const mqttStatusHighlight = (
- { enabled, connected }: MqttStatus,
- theme: Theme
-) => {
- if (!enabled) {
- return theme.palette.info.main;
- }
- if (connected) {
- return theme.palette.success.main;
- }
- return theme.palette.error.main;
-};
-
-export const mqttStatus = ({ enabled, connected }: MqttStatus) => {
- if (!enabled) {
- return 'Not enabled';
- }
- if (connected) {
- return 'Connected';
- }
- return 'Disconnected';
-};
-
-export const disconnectReason = ({ disconnect_reason }: MqttStatus) => {
- switch (disconnect_reason) {
- case MqttDisconnectReason.TCP_DISCONNECTED:
- return 'TCP disconnected';
- case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
- return 'Unacceptable protocol version';
- case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
- return 'Client ID rejected';
- case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
- return 'Server unavailable';
- case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
- return 'Malformed credentials';
- case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
- return 'Not authorized';
- case MqttDisconnectReason.ESP8266_NOT_ENOUGH_SPACE:
- return 'Device out of memory';
- case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
- return 'Server fingerprint invalid';
- default:
- return 'Unknown';
- }
-};
-
-export const mqttPublishHighlight = (
- { mqtt_fails }: MqttStatus,
- theme: Theme
-) => {
- if (mqtt_fails === 0) return theme.palette.success.main;
-
- if (mqtt_fails < 10) return theme.palette.warning.main;
-
- return theme.palette.error.main;
-};
diff --git a/interface/src/mqtt/MqttStatusController.tsx b/interface/src/mqtt/MqttStatusController.tsx
deleted file mode 100644
index 80eb646c3..000000000
--- a/interface/src/mqtt/MqttStatusController.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import React, { Component } from 'react';
-
-import {
- restController,
- RestControllerProps,
- RestFormLoader,
- SectionContent
-} from '../components';
-import { MQTT_STATUS_ENDPOINT } from '../api';
-
-import MqttStatusForm from './MqttStatusForm';
-import { MqttStatus } from './types';
-
-type MqttStatusControllerProps = RestControllerProps;
-
-class MqttStatusController extends Component {
- componentDidMount() {
- this.props.loadData();
- }
-
- render() {
- return (
-
- }
- />
-
- );
- }
-}
-
-export default restController(MQTT_STATUS_ENDPOINT, MqttStatusController);
diff --git a/interface/src/mqtt/MqttStatusForm.tsx b/interface/src/mqtt/MqttStatusForm.tsx
deleted file mode 100644
index 5d3eefd2e..000000000
--- a/interface/src/mqtt/MqttStatusForm.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import { Component, Fragment } from 'react';
-
-import { WithTheme, withTheme } from '@material-ui/core/styles';
-import {
- Avatar,
- Divider,
- List,
- ListItem,
- ListItemAvatar,
- ListItemText
-} from '@material-ui/core';
-
-import DeviceHubIcon from '@material-ui/icons/DeviceHub';
-import RefreshIcon from '@material-ui/icons/Refresh';
-import ReportIcon from '@material-ui/icons/Report';
-import SpeakerNotesOffIcon from '@material-ui/icons/SpeakerNotesOff';
-
-import {
- RestFormProps,
- FormActions,
- FormButton,
- HighlightAvatar
-} from '../components';
-import {
- mqttStatusHighlight,
- mqttStatus,
- mqttPublishHighlight,
- disconnectReason
-} from './MqttStatus';
-import { MqttStatus } from './types';
-
-type MqttStatusFormProps = RestFormProps & WithTheme;
-
-class MqttStatusForm extends Component {
- renderConnectionStatus() {
- const { data, theme } = this.props;
- if (data.connected) {
- return (
-
-
-
- #
-
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
- return (
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-
- createListItems() {
- const { data, theme } = this.props;
- return (
-
-
-
-
-
-
-
-
-
-
- {data.enabled && this.renderConnectionStatus()}
-
- );
- }
-
- render() {
- return (
-
- {this.createListItems()}
-
- }
- variant="contained"
- color="secondary"
- onClick={this.props.loadData}
- >
- Refresh
-
-
-
- );
- }
-}
-
-export default withTheme(MqttStatusForm);
diff --git a/interface/src/network/NetworkConnection.tsx b/interface/src/network/NetworkConnection.tsx
deleted file mode 100644
index e8433e2af..000000000
--- a/interface/src/network/NetworkConnection.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import { Component } from 'react';
-import { Redirect, Switch, RouteComponentProps } from 'react-router-dom';
-
-import { Tabs, Tab } from '@material-ui/core';
-
-import {
- withAuthenticatedContext,
- AuthenticatedContextProps,
- AuthenticatedRoute
-} from '../authentication';
-import { MenuAppBar } from '../components';
-
-import NetworkStatusController from './NetworkStatusController';
-import NetworkSettingsController from './NetworkSettingsController';
-import WiFiNetworkScanner from './WiFiNetworkScanner';
-import {
- NetworkConnectionContext,
- NetworkConnectionContextValue
-} from './NetworkConnectionContext';
-
-import { WiFiNetwork } from './types';
-
-type NetworkConnectionProps = AuthenticatedContextProps & RouteComponentProps;
-
-class NetworkConnection extends Component<
- NetworkConnectionProps,
- NetworkConnectionContextValue
-> {
- constructor(props: NetworkConnectionProps) {
- super(props);
- this.state = {
- selectNetwork: this.selectNetwork,
- deselectNetwork: this.deselectNetwork
- };
- }
-
- selectNetwork = (network: WiFiNetwork) => {
- this.setState({ selectedNetwork: network });
- this.props.history.push('/network/settings');
- };
-
- deselectNetwork = () => {
- this.setState({ selectedNetwork: undefined });
- };
-
- handleTabChange = (path: string) => {
- this.props.history.push(path);
- };
-
- render() {
- const { authenticatedContext } = this.props;
- return (
-
-
- this.handleTabChange(path)}
- variant="fullWidth"
- >
-
-
-
-
-
-
-
-
-
-
-
-
- );
- }
-}
-
-export default withAuthenticatedContext(NetworkConnection);
diff --git a/interface/src/network/NetworkConnectionContext.tsx b/interface/src/network/NetworkConnectionContext.tsx
deleted file mode 100644
index 600331199..000000000
--- a/interface/src/network/NetworkConnectionContext.tsx
+++ /dev/null
@@ -1,13 +0,0 @@
-import React from 'react';
-import { WiFiNetwork } from './types';
-
-export interface NetworkConnectionContextValue {
- selectedNetwork?: WiFiNetwork;
- selectNetwork: (network: WiFiNetwork) => void;
- deselectNetwork: () => void;
-}
-
-const NetworkConnectionContextDefaultValue = {} as NetworkConnectionContextValue;
-export const NetworkConnectionContext = React.createContext(
- NetworkConnectionContextDefaultValue
-);
diff --git a/interface/src/network/NetworkSettingsController.tsx b/interface/src/network/NetworkSettingsController.tsx
deleted file mode 100644
index 5a96bc25b..000000000
--- a/interface/src/network/NetworkSettingsController.tsx
+++ /dev/null
@@ -1,35 +0,0 @@
-import { Component } from 'react';
-
-import {
- restController,
- RestControllerProps,
- RestFormLoader,
- SectionContent
-} from '../components';
-import NetworkSettingsForm from './NetworkSettingsForm';
-import { NETWORK_SETTINGS_ENDPOINT } from '../api';
-import { NetworkSettings } from './types';
-
-type NetworkSettingsControllerProps = RestControllerProps;
-
-class NetworkSettingsController extends Component {
- componentDidMount() {
- this.props.loadData();
- }
-
- render() {
- return (
-
- }
- />
-
- );
- }
-}
-
-export default restController(
- NETWORK_SETTINGS_ENDPOINT,
- NetworkSettingsController
-);
diff --git a/interface/src/network/NetworkSettingsForm.tsx b/interface/src/network/NetworkSettingsForm.tsx
deleted file mode 100644
index 24c482de8..000000000
--- a/interface/src/network/NetworkSettingsForm.tsx
+++ /dev/null
@@ -1,286 +0,0 @@
-import React, { Fragment } from 'react';
-import { TextValidator, ValidatorForm } from 'react-material-ui-form-validator';
-
-import {
- Checkbox,
- List,
- ListItem,
- ListItemText,
- ListItemAvatar,
- ListItemSecondaryAction
-} from '@material-ui/core';
-
-import Avatar from '@material-ui/core/Avatar';
-import IconButton from '@material-ui/core/IconButton';
-import LockIcon from '@material-ui/icons/Lock';
-import LockOpenIcon from '@material-ui/icons/LockOpen';
-import DeleteIcon from '@material-ui/icons/Delete';
-import SaveIcon from '@material-ui/icons/Save';
-
-import {
- RestFormProps,
- PasswordValidator,
- BlockFormControlLabel,
- FormActions,
- FormButton
-} from '../components';
-import { isIP, isHostname, optional } from '../validators';
-
-import {
- NetworkConnectionContext,
- NetworkConnectionContextValue
-} from './NetworkConnectionContext';
-import { isNetworkOpen, networkSecurityMode } from './WiFiSecurityModes';
-import { NetworkSettings } from './types';
-
-type NetworkStatusFormProps = RestFormProps;
-
-class NetworkSettingsForm extends React.Component {
- static contextType = NetworkConnectionContext;
- context!: React.ContextType;
-
- constructor(
- props: NetworkStatusFormProps,
- context: NetworkConnectionContextValue
- ) {
- super(props);
-
- const { selectedNetwork } = context;
- if (selectedNetwork) {
- const networkSettings: NetworkSettings = {
- ssid: selectedNetwork.ssid,
- password: '',
- hostname: props.data.hostname,
- static_ip_config: false,
- enableIPv6: false,
- bandwidth20: false,
- tx_power: 20,
- nosleep: false
- };
- props.setData(networkSettings);
- }
- }
-
- componentWillMount() {
- ValidatorForm.addValidationRule('isIP', isIP);
- ValidatorForm.addValidationRule('isHostname', isHostname);
- ValidatorForm.addValidationRule('isOptionalIP', optional(isIP));
- }
-
- deselectNetworkAndLoadData = () => {
- this.context.deselectNetwork();
- this.props.loadData();
- };
-
- componentWillUnmount() {
- this.context.deselectNetwork();
- }
-
- render() {
- const { selectedNetwork, deselectNetwork } = this.context;
- const { data, handleValueChange, saveData } = this.props;
- return (
-
- {selectedNetwork ? (
-
-
-
-
- {isNetworkOpen(selectedNetwork) ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
- ) : (
-
- )}
- {(!selectedNetwork || !isNetworkOpen(selectedNetwork)) && (
-
- )}
-
-
-
- }
- label="Enable IPv6 support"
- />
-
- }
- label="Use Lower WiFi Bandwidth"
- />
-
- }
- label="Disable WiFi Sleep Mode"
- />
-
- }
- label="Use Static IPs"
- />
- {data.static_ip_config && (
-
-
-
-
-
-
-
- )}
-
- }
- variant="contained"
- color="primary"
- type="submit"
- >
- Save
-
-
-
- );
- }
-}
-
-export default NetworkSettingsForm;
diff --git a/interface/src/network/NetworkStatus.ts b/interface/src/network/NetworkStatus.ts
deleted file mode 100644
index c75b14531..000000000
--- a/interface/src/network/NetworkStatus.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { Theme } from '@material-ui/core';
-import { NetworkStatus, NetworkConnectionStatus } from './types';
-
-export const isConnected = ({ status }: NetworkStatus) => {
- return (
- status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
- status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED
- );
-};
-
-export const isWiFi = ({ status }: NetworkStatus) =>
- status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
-export const isEthernet = ({ status }: NetworkStatus) =>
- status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
-
-export const networkStatusHighlight = (
- { status }: NetworkStatus,
- theme: Theme
-) => {
- switch (status) {
- case NetworkConnectionStatus.WIFI_STATUS_IDLE:
- case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
- case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
- return theme.palette.info.main;
- case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
- case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
- return theme.palette.success.main;
- case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
- case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
- return theme.palette.error.main;
- default:
- return theme.palette.warning.main;
- }
-};
-
-export const networkStatus = ({ status }: NetworkStatus) => {
- switch (status) {
- case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
- return 'Inactive';
- case NetworkConnectionStatus.WIFI_STATUS_IDLE:
- return 'Idle';
- case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
- return 'No SSID Available';
- case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
- return 'Connected (WiFi)';
- case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
- return 'Connected (Ethernet)';
- case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
- return 'Connection Failed';
- case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
- return 'Connection Lost';
- case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
- return 'Disconnected';
- default:
- return 'Unknown';
- }
-};
diff --git a/interface/src/network/NetworkStatusController.tsx b/interface/src/network/NetworkStatusController.tsx
deleted file mode 100644
index 0c813fcff..000000000
--- a/interface/src/network/NetworkStatusController.tsx
+++ /dev/null
@@ -1,32 +0,0 @@
-import { Component } from 'react';
-
-import {
- restController,
- RestControllerProps,
- RestFormLoader,
- SectionContent
-} from '../components';
-import NetworkStatusForm from './NetworkStatusForm';
-import { NETWORK_STATUS_ENDPOINT } from '../api';
-import { NetworkStatus } from './types';
-
-type NetworkStatusControllerProps = RestControllerProps;
-
-class NetworkStatusController extends Component {
- componentDidMount() {
- this.props.loadData();
- }
-
- render() {
- return (
-
- }
- />
-
- );
- }
-}
-
-export default restController(NETWORK_STATUS_ENDPOINT, NetworkStatusController);
diff --git a/interface/src/network/NetworkStatusForm.tsx b/interface/src/network/NetworkStatusForm.tsx
deleted file mode 100644
index 6098f04c6..000000000
--- a/interface/src/network/NetworkStatusForm.tsx
+++ /dev/null
@@ -1,169 +0,0 @@
-import { Component, Fragment } from 'react';
-
-import { WithTheme, withTheme } from '@material-ui/core/styles';
-import {
- Avatar,
- Divider,
- List,
- ListItem,
- ListItemAvatar,
- ListItemText
-} from '@material-ui/core';
-
-import DNSIcon from '@material-ui/icons/Dns';
-import WifiIcon from '@material-ui/icons/Wifi';
-import RouterIcon from '@material-ui/icons/Router';
-import SettingsInputComponentIcon from '@material-ui/icons/SettingsInputComponent';
-import SettingsInputAntennaIcon from '@material-ui/icons/SettingsInputAntenna';
-import DeviceHubIcon from '@material-ui/icons/DeviceHub';
-import RefreshIcon from '@material-ui/icons/Refresh';
-
-import {
- RestFormProps,
- FormActions,
- FormButton,
- HighlightAvatar
-} from '../components';
-import {
- networkStatus,
- networkStatusHighlight,
- isConnected,
- isWiFi,
- isEthernet
-} from './NetworkStatus';
-import { NetworkStatus } from './types';
-
-type NetworkStatusFormProps = RestFormProps & WithTheme;
-
-class NetworkStatusForm extends Component {
- dnsServers(status: NetworkStatus) {
- if (!status.dns_ip_1) {
- return 'none';
- }
- if (!status.dns_ip_2 || status.dns_ip_2 === '0.0.0.0') {
- return status.dns_ip_1;
- }
- return status.dns_ip_1 + ', ' + status.dns_ip_2;
- }
- IPs(status: NetworkStatus) {
- if (
- !status.local_ipv6 ||
- status.local_ipv6 === '0000:0000:0000:0000:0000:0000:0000:0000'
- ) {
- return status.local_ip;
- }
- if (!status.local_ip || status.local_ip === '0.0.0.0') {
- return status.local_ipv6;
- }
- return status.local_ip + ', ' + status.local_ipv6;
- }
-
- createListItems() {
- const { data, theme } = this.props;
-
- return (
-
-
-
-
- {isWiFi(data) && }
- {isEthernet(data) && }
-
-
-
-
-
- {isWiFi(data) && (
-
-
-
-
-
-
-
-
-
-
-
- )}
- {isConnected(data) && (
-
-
-
- IP
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- #
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )}
-
- );
- }
-
- render() {
- return (
-
- {this.createListItems()}
-
- }
- variant="contained"
- color="secondary"
- onClick={this.props.loadData}
- >
- Refresh
-
-
-
- );
- }
-}
-
-export default withTheme(NetworkStatusForm);
diff --git a/interface/src/network/WiFiNetworkScanner.tsx b/interface/src/network/WiFiNetworkScanner.tsx
deleted file mode 100644
index ecd470701..000000000
--- a/interface/src/network/WiFiNetworkScanner.tsx
+++ /dev/null
@@ -1,203 +0,0 @@
-import { Component } from 'react';
-import { withSnackbar, WithSnackbarProps } from 'notistack';
-
-import {
- createStyles,
- WithStyles,
- Theme,
- withStyles,
- Typography,
- LinearProgress
-} from '@material-ui/core';
-import PermScanWifiIcon from '@material-ui/icons/PermScanWifi';
-
-import { FormActions, FormButton, SectionContent } from '../components';
-import { redirectingAuthorizedFetch } from '../authentication';
-import { SCAN_NETWORKS_ENDPOINT, LIST_NETWORKS_ENDPOINT } from '../api';
-
-import WiFiNetworkSelector from './WiFiNetworkSelector';
-import { WiFiNetworkList, WiFiNetwork } from './types';
-
-const NUM_POLLS = 10;
-const POLLING_FREQUENCY = 500;
-const RETRY_EXCEPTION_TYPE = 'retry';
-
-interface WiFiNetworkScannerState {
- scanningForNetworks: boolean;
- errorMessage?: string;
- networkList?: WiFiNetworkList;
-}
-
-const styles = (theme: Theme) =>
- createStyles({
- scanningSettings: {
- margin: theme.spacing(0.5)
- },
- scanningSettingsDetails: {
- margin: theme.spacing(4),
- textAlign: 'center'
- },
- scanningProgress: {
- margin: theme.spacing(4),
- textAlign: 'center'
- }
- });
-
-type WiFiNetworkScannerProps = WithSnackbarProps & WithStyles;
-
-class WiFiNetworkScanner extends Component<
- WiFiNetworkScannerProps,
- WiFiNetworkScannerState
-> {
- pollCount = 0;
-
- state: WiFiNetworkScannerState = {
- scanningForNetworks: false
- };
-
- componentDidMount() {
- this.scanNetworks();
- }
-
- requestNetworkScan = () => {
- const { scanningForNetworks } = this.state;
- if (!scanningForNetworks) {
- this.scanNetworks();
- }
- };
-
- scanNetworks() {
- this.pollCount = 0;
- this.setState({
- scanningForNetworks: true,
- networkList: undefined,
- errorMessage: undefined
- });
- redirectingAuthorizedFetch(SCAN_NETWORKS_ENDPOINT)
- .then((response) => {
- if (response.status === 202) {
- this.schedulePollTimeout();
- return;
- }
- throw Error(
- 'Scanning for networks returned unexpected response code: ' +
- response.status
- );
- })
- .catch((error) => {
- this.props.enqueueSnackbar('Problem scanning: ' + error.message, {
- variant: 'error'
- });
- this.setState({
- scanningForNetworks: false,
- networkList: undefined,
- errorMessage: error.message
- });
- });
- }
-
- schedulePollTimeout() {
- setTimeout(this.pollNetworkList, POLLING_FREQUENCY);
- }
-
- retryError() {
- return {
- name: RETRY_EXCEPTION_TYPE,
- message:
- 'Network list not ready, will retry in ' + POLLING_FREQUENCY + 'ms.'
- };
- }
-
- compareNetworks(network1: WiFiNetwork, network2: WiFiNetwork) {
- if (network1.rssi < network2.rssi) return 1;
- if (network1.rssi > network2.rssi) return -1;
- return 0;
- }
-
- pollNetworkList = () => {
- redirectingAuthorizedFetch(LIST_NETWORKS_ENDPOINT)
- .then((response) => {
- if (response.status === 200) {
- return response.json();
- }
- if (response.status === 202) {
- if (++this.pollCount < NUM_POLLS) {
- this.schedulePollTimeout();
- throw this.retryError();
- } else {
- throw Error('Device did not return network list in timely manner.');
- }
- }
- throw Error(
- 'Device returned unexpected response code: ' + response.status
- );
- })
- .then((json) => {
- json.networks.sort(this.compareNetworks);
- this.setState({
- scanningForNetworks: false,
- networkList: json,
- errorMessage: undefined
- });
- })
- .catch((error) => {
- if (error.name !== RETRY_EXCEPTION_TYPE) {
- this.props.enqueueSnackbar('Problem scanning: ' + error.message, {
- variant: 'error'
- });
- this.setState({
- scanningForNetworks: false,
- networkList: undefined,
- errorMessage: error.message
- });
- }
- });
- };
-
- renderNetworkScanner() {
- const { classes } = this.props;
- const { scanningForNetworks, networkList, errorMessage } = this.state;
- if (scanningForNetworks || !networkList) {
- return (
-
-
-
- Scanning…
-
-
- );
- }
- if (errorMessage) {
- return (
-
-
- {errorMessage}
-
-
- );
- }
- return ;
- }
-
- render() {
- const { scanningForNetworks } = this.state;
- return (
-
- {this.renderNetworkScanner()}
-
- }
- variant="contained"
- color="secondary"
- onClick={this.requestNetworkScan}
- disabled={scanningForNetworks}
- >
- Scan again…
-
-
-
- );
- }
-}
-
-export default withSnackbar(withStyles(styles)(WiFiNetworkScanner));
diff --git a/interface/src/network/WiFiNetworkSelector.tsx b/interface/src/network/WiFiNetworkSelector.tsx
deleted file mode 100644
index f63347162..000000000
--- a/interface/src/network/WiFiNetworkSelector.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-import React, { Component } from 'react';
-
-import { Avatar, Badge } from '@material-ui/core';
-import {
- List,
- ListItem,
- ListItemIcon,
- ListItemText,
- ListItemAvatar
-} from '@material-ui/core';
-
-import WifiIcon from '@material-ui/icons/Wifi';
-import LockIcon from '@material-ui/icons/Lock';
-import LockOpenIcon from '@material-ui/icons/LockOpen';
-
-import { isNetworkOpen, networkSecurityMode } from './WiFiSecurityModes';
-import { NetworkConnectionContext } from './NetworkConnectionContext';
-import { WiFiNetwork, WiFiNetworkList } from './types';
-
-interface WiFiNetworkSelectorProps {
- networkList: WiFiNetworkList;
-}
-
-class WiFiNetworkSelector extends Component {
- static contextType = NetworkConnectionContext;
- context!: React.ContextType;
-
- renderNetwork = (network: WiFiNetwork) => {
- return (
- this.context.selectNetwork(network)}
- >
-
-
- {isNetworkOpen(network) ? : }
-
-
-
-
-
-
-
-
-
- );
- };
-
- render() {
- return (
- {this.props.networkList.networks.map(this.renderNetwork)}
- );
- }
-}
-
-export default WiFiNetworkSelector;
diff --git a/interface/src/network/WiFiSecurityModes.ts b/interface/src/network/WiFiSecurityModes.ts
deleted file mode 100644
index 40a055f9d..000000000
--- a/interface/src/network/WiFiSecurityModes.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import { WiFiNetwork, WiFiEncryptionType } from './types';
-
-export const isNetworkOpen = ({ encryption_type }: WiFiNetwork) =>
- encryption_type === WiFiEncryptionType.WIFI_AUTH_OPEN;
-
-export const networkSecurityMode = ({ encryption_type }: WiFiNetwork) => {
- switch (encryption_type) {
- case WiFiEncryptionType.WIFI_AUTH_WEP:
- return 'WEP';
- case WiFiEncryptionType.WIFI_AUTH_WPA_PSK:
- return 'WPA';
- case WiFiEncryptionType.WIFI_AUTH_WPA2_PSK:
- return 'WPA2';
- case WiFiEncryptionType.WIFI_AUTH_WPA_WPA2_PSK:
- return 'WPA/WPA2';
- case WiFiEncryptionType.WIFI_AUTH_WPA2_ENTERPRISE:
- return 'WPA2 Enterprise';
- case WiFiEncryptionType.WIFI_AUTH_OPEN:
- return 'None';
- default:
- return 'Unknown';
- }
-};
diff --git a/interface/src/ntp/NTPSettingsController.tsx b/interface/src/ntp/NTPSettingsController.tsx
deleted file mode 100644
index fa7e3cb2f..000000000
--- a/interface/src/ntp/NTPSettingsController.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Component } from 'react';
-
-import {
- restController,
- RestControllerProps,
- RestFormLoader,
- SectionContent
-} from '../components';
-import { NTP_SETTINGS_ENDPOINT } from '../api';
-
-import NTPSettingsForm from './NTPSettingsForm';
-import { NTPSettings } from './types';
-
-type NTPSettingsControllerProps = RestControllerProps;
-
-class NTPSettingsController extends Component {
- componentDidMount() {
- this.props.loadData();
- }
-
- render() {
- return (
-
- }
- />
-
- );
- }
-}
-
-export default restController(NTP_SETTINGS_ENDPOINT, NTPSettingsController);
diff --git a/interface/src/ntp/NTPSettingsForm.tsx b/interface/src/ntp/NTPSettingsForm.tsx
deleted file mode 100644
index aff27a87c..000000000
--- a/interface/src/ntp/NTPSettingsForm.tsx
+++ /dev/null
@@ -1,96 +0,0 @@
-import React from 'react';
-import {
- TextValidator,
- ValidatorForm,
- SelectValidator
-} from 'react-material-ui-form-validator';
-
-import { Checkbox, MenuItem } from '@material-ui/core';
-import SaveIcon from '@material-ui/icons/Save';
-
-import {
- RestFormProps,
- FormActions,
- FormButton,
- BlockFormControlLabel
-} from '../components';
-import { isIP, isHostname, or } from '../validators';
-
-import { TIME_ZONES, timeZoneSelectItems, selectedTimeZone } from './TZ';
-import { NTPSettings } from './types';
-
-type NTPSettingsFormProps = RestFormProps;
-
-class NTPSettingsForm extends React.Component {
- componentDidMount() {
- ValidatorForm.addValidationRule('isIPOrHostname', or(isIP, isHostname));
- }
-
- changeTimeZone = (event: React.ChangeEvent) => {
- const { data, setData } = this.props;
- setData({
- ...data,
- tz_label: event.target.value,
- tz_format: TIME_ZONES[event.target.value]
- });
- };
-
- render() {
- const { data, handleValueChange, saveData } = this.props;
- return (
-
-
- }
- label="Enable NTP"
- />
-
-
-
- {timeZoneSelectItems()}
-
-
- }
- variant="contained"
- color="primary"
- type="submit"
- >
- Save
-
-
-
- );
- }
-}
-
-export default NTPSettingsForm;
diff --git a/interface/src/ntp/NTPStatus.ts b/interface/src/ntp/NTPStatus.ts
deleted file mode 100644
index 4e490bbe4..000000000
--- a/interface/src/ntp/NTPStatus.ts
+++ /dev/null
@@ -1,27 +0,0 @@
-import { Theme } from '@material-ui/core';
-import { NTPStatus, NTPSyncStatus } from './types';
-
-export const isNtpActive = ({ status }: NTPStatus) =>
- status === NTPSyncStatus.NTP_ACTIVE;
-
-export const ntpStatusHighlight = ({ status }: NTPStatus, theme: Theme) => {
- switch (status) {
- case NTPSyncStatus.NTP_INACTIVE:
- return theme.palette.info.main;
- case NTPSyncStatus.NTP_ACTIVE:
- return theme.palette.success.main;
- default:
- return theme.palette.error.main;
- }
-};
-
-export const ntpStatus = ({ status }: NTPStatus) => {
- switch (status) {
- case NTPSyncStatus.NTP_INACTIVE:
- return 'Inactive';
- case NTPSyncStatus.NTP_ACTIVE:
- return 'Active';
- default:
- return 'Unknown';
- }
-};
diff --git a/interface/src/ntp/NTPStatusController.tsx b/interface/src/ntp/NTPStatusController.tsx
deleted file mode 100644
index 56c8e970e..000000000
--- a/interface/src/ntp/NTPStatusController.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import { Component } from 'react';
-
-import {
- restController,
- RestControllerProps,
- RestFormLoader,
- SectionContent
-} from '../components';
-import { NTP_STATUS_ENDPOINT } from '../api';
-
-import NTPStatusForm from './NTPStatusForm';
-import { NTPStatus } from './types';
-
-type NTPStatusControllerProps = RestControllerProps;
-
-class NTPStatusController extends Component {
- componentDidMount() {
- this.props.loadData();
- }
-
- render() {
- return (
-
-