admin panel

This commit is contained in:
Svante Kaiser 2023-11-30 19:30:51 +03:00
parent 5f1849caf1
commit 0c74411808
231 changed files with 57372 additions and 22 deletions

3
.gitignore vendored

@ -3,3 +3,6 @@
.vscode/c_cpp_properties.json
.vscode/launch.json
.vscode/ipch
build
node_modules

BIN
data/www/app/icon.png.gz Normal file

Binary file not shown.

Binary file not shown.

BIN
data/www/css/roboto.css.gz Normal file

Binary file not shown.

BIN
data/www/favicon.ico Normal file

Binary file not shown.

After

Width: 256px  |  Height: 256px  |  Size: 25 KiB

BIN
data/www/favicon.ico.gz Normal file

Binary file not shown.

BIN
data/www/fonts/md.woff2.gz Normal file

Binary file not shown.

BIN
data/www/fonts/re.woff2.gz Normal file

Binary file not shown.

BIN
data/www/index.html.gz Normal file

Binary file not shown.

BIN
data/www/js/179.fa22.js.gz Normal file

Binary file not shown.

14
frontend2/.eslintrc.cjs Normal file

@ -0,0 +1,14 @@
module.exports = {
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:react-hooks/recommended',
],
parser: '@typescript-eslint/parser',
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': 'warn',
},
}

22
frontend2/.gitignore vendored Normal file

@ -0,0 +1,22 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

BIN
frontend2/.vs/slnx.sqlite Normal file

Binary file not shown.

Binary file not shown.

@ -0,0 +1,20 @@
{
"filetypes": [
".html",
".js",
".css",
".svg",
".png",
".jpg",
".ico",
".txt",
".json",
".mp3",
".wav",
".tff",
".woff2"
],
"algorithms": ["gz"],
"directory": "../data/www",
"retainUncompressedFiles": false
}

BIN
frontend2/favicon.ico Normal file

Binary file not shown.

After

Width: 256px  |  Height: 256px  |  Size: 25 KiB

16
frontend2/index.html Normal file

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="Captive Portal website written in React" />
<title>Digitum Admin Panel</title>
</head>
<body>
<div id="🌐"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5837
frontend2/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

35
frontend2/package.json Normal file

@ -0,0 +1,35 @@
{
"name": "react-websocket-client",
"type": "module",
"version": "0.1.1",
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"build:compressed": "tsc && vite build && compress-cra -c compress-cra.json",
"lint": "eslint src --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@emotion/react": "^11.11.0",
"@emotion/styled": "^11.11.0",
"@mui/material": "^5.13.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"websocket": "^1.0.34"
},
"devDependencies": {
"@types/react": "^18.0.28",
"@types/react-dom": "^18.0.11",
"@types/websocket": "^1.0.5",
"@typescript-eslint/eslint-plugin": "^5.57.1",
"@typescript-eslint/parser": "^5.57.1",
"@vitejs/plugin-react-swc": "^3.0.0",
"compress-create-react-app": "^1.4.2",
"eslint": "^8.38.0",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.3.4",
"typescript": "^5.0.2",
"vite": "^4.3.2"
}
}

18
frontend2/src/App.css Normal file

@ -0,0 +1,18 @@
.wrapper,
html,
body {
height: 100%;
margin: 0;
}
.wrapper {
display: flex;
flex-direction: column;
}
.centered {
height: 100dvh;
display: flex;
justify-content: center;
align-items: center;
}

33
frontend2/src/App.tsx Normal file

@ -0,0 +1,33 @@
import { useState } from 'react';
function App() {
const [responseMessage, setResponseMessage] = useState('');
const handleButtonClick = () => {
fetch('http://%API_URL%/api/v1/door/open')
.then(response => {
if (response.status === 200) {
setResponseMessage('Successful');
} else {
setResponseMessage('Request failed');
}
})
.catch(() => {
setResponseMessage('Request failed');
});
};
return (
<div className="centered">
<div className="wrapper">
<h1>
<span>Door</span>
</h1>
<button onClick={handleButtonClick}>Open door</button>
<p>{responseMessage}</p>
</div>
</div>
);
}
export default App;

47
frontend2/src/AppOld.tsx Normal file

@ -0,0 +1,47 @@
import "./App.css";
import { IMessageEvent, w3cwebsocket } from "websocket";
import { useState, useEffect, useRef, useCallback } from "react";
import Button from "@mui/material/Button";
function App() {
const websocket = useRef<w3cwebsocket | null>(null);
const [LED, setLED] = useState(false);
useEffect(() => {
websocket.current = new w3cwebsocket("ws://192.168.2.1/ws");
websocket.current.onmessage = (message: IMessageEvent) => {
const dataFromServer = JSON.parse(message.data.toString());
if (dataFromServer.type === "message") {
setLED(dataFromServer.LED);
}
};
return () => websocket.current?.close();
}, []);
const sendUpdate = useCallback(({ led }: { led: boolean }) => {
websocket.current?.send(
JSON.stringify({
type: "message",
LED: led,
})
);
}, []);
const toggleLed = useCallback(() => sendUpdate({ led: !LED }), [LED, sendUpdate]);
return (
<div className="centered">
<div className="wrapper">
<h1>
<span>Currently </span>
<span>{LED ? "ON" : "OFF"}</span>
</h1>
<Button variant="contained" onClick={toggleLed}>
{LED ? "Turn Off" : "Turn On"}
</Button>
</div>
</div>
);
}
export default App;

6
frontend2/src/index.css Normal file

@ -0,0 +1,6 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

10
frontend2/src/main.tsx Normal file

@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('🌐') as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

1
frontend2/src/vite-env.d.ts vendored Normal file

@ -0,0 +1 @@
/// <reference types="vite/client" />

21
frontend2/tsconfig.json Normal file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"allowJs": false,
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

11
frontend2/vite.config.ts Normal file

@ -0,0 +1,11 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react-swc'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
build: {
emptyOutDir: true,
outDir: '../data/www',
},
})

8
interface/.env Normal file

@ -0,0 +1,8 @@
# This enables lint extensions
EXTEND_ESLINT=true
# This is the name of your project. It appears on the sign-in page and in the menu bar.
REACT_APP_PROJECT_NAME=Digitum
# This is the url path your project will be exposed under.
REACT_APP_PROJECT_PATH=project

@ -0,0 +1,2 @@
# Disable the generation of the sourcemap on the production build to reduce the artefact size
GENERATE_SOURCEMAP=false

@ -0,0 +1,20 @@
{
"filetypes": [
".html",
".js",
".css",
".svg",
".png",
".jpg",
".ico",
".txt",
".json",
".mp3",
".wav",
".tff",
".woff2"
],
"algorithms": ["gz"],
"directory": "build",
"retainUncompressedFiles": false
}

@ -0,0 +1,28 @@
const { WebpackManifestPlugin } = require('webpack-manifest-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const ProgmemGenerator = require('./progmem-generator.js');
const TerserPlugin = require('terser-webpack-plugin');
module.exports = function override(config, env) {
if (env === "production") {
// rename the ouput file, we need it's path to be short, for embedded FS
config.output.filename = 'js/[id].[chunkhash:4].js';
config.output.chunkFilename = 'js/[id].[chunkhash:4].js';
// take out the manifest plugin
config.plugins = config.plugins.filter((plugin) => !(plugin instanceof WebpackManifestPlugin));
// shorten css filenames
const miniCssExtractPlugin = config.plugins.find((plugin) => plugin instanceof MiniCssExtractPlugin);
miniCssExtractPlugin.options.filename = "css/[id].[contenthash:4].css";
miniCssExtractPlugin.options.chunkFilename = "css/[id].[contenthash:4].c.css";
// don't emit license file
const terserPlugin = config.optimization.minimizer.find((plugin) => plugin instanceof TerserPlugin);
terserPlugin.options.extractComments = false;
// build progmem data files
config.plugins.push(new ProgmemGenerator({ outputPath: "../lib/framework/WWWData.h", bytesPerLine: 20 }));
}
return config;
};

BIN
interface/favicon.ico Normal file

Binary file not shown.

After

Width: 256px  |  Height: 256px  |  Size: 25 KiB

28299
interface/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

93
interface/package.json Normal file

@ -0,0 +1,93 @@
{
"name": "esp8266-react",
"version": "0.1.0",
"private": true,
"proxy": "http://192.168.0.23",
"dependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/icons-material": "^5.8.0",
"@mui/material": "^5.8.0",
"@types/lodash": "^4.14.176",
"@types/node": "^16.11.14",
"@types/react": "^18.0.9",
"@types/react-dom": "^18.0.4",
"async-validator": "^4.1.1",
"axios": "^0.27.2",
"http-proxy-middleware": "^2.0.1",
"jwt-decode": "^3.1.2",
"lodash": "^4.17.21",
"notistack": "^2.0.5",
"parse-ms": "^3.0.0",
"react": "^18.1.0",
"react-app-rewired": "^2.1.8",
"react-dom": "^18.1.0",
"react-dropzone": "^11.4.2",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"sockette": "^2.0.6",
"typescript": "^4.6.4"
},
"devDependencies": {
"compress-create-react-app": "^1.4.2"
},
"scripts": {
"start": "react-app-rewired start",
"build": "react-app-rewired build",
"build:compressed": "react-app-rewired build && compress-cra -c compress-cra.json",
"test": "react-app-rewired test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
],
"rules": {
"eol-last": 1,
"react/jsx-closing-bracket-location": 1,
"react/jsx-closing-tag-location": 1,
"react/jsx-wrap-multilines": 1,
"react/jsx-curly-newline": 1,
"no-multiple-empty-lines": [
1,
{
"max": 1
}
],
"no-trailing-spaces": 1,
"semi": 1,
"no-extra-semi": 1,
"react/jsx-max-props-per-line": [
1,
{
"when": "multiline"
}
],
"react/jsx-first-prop-new-line": [
1,
"multiline"
],
"@typescript-eslint/no-shadow": 1,
"max-len": [
1,
{
"code": 140
}
],
"arrow-parens": 1
}
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

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

Binary file not shown.

After

(image error) Size: 7.0 KiB

@ -0,0 +1,12 @@
{
"name":"ESP8266 React",
"icons":[
{
"src":"/app/icon.png",
"sizes":"48x48 72x72 96x96 128x128 256x256"
}
],
"start_url":"/",
"display":"fullscreen",
"orientation":"any"
}

@ -0,0 +1,22 @@
/*
* Just supporting latin due to size constrains on the esp chip
*
* The framework only makes use of 400 (regular) + 500 (medium) weight fonts.
*
* If using light or strong typography variants you will need to add additional fonts.
*/
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
src: local('Roboto'), local('Roboto-Regular'), url(../fonts/re.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
src: local('Roboto Medium'), local('Roboto-Medium'), url(../fonts/md.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+2000-206F, U+2074, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

Binary file not shown.

After

Width: 256px  |  Height: 256px  |  Size: 25 KiB

Binary file not shown.

Binary file not shown.

@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=0, maximum-scale=1, minimum-scale=1">
<link rel="stylesheet" href="%PUBLIC_URL%/css/roboto.css">
<link rel="manifest" href="%PUBLIC_URL%/app/manifest.json">
<title>Digitum</title>
</head>
<body>
<noscript>
You need to enable JavaScript to run this app.
</noscript>
<div id="root"></div>
</body>
</html>

39
interface/src/App.tsx Normal file

@ -0,0 +1,39 @@
import React, { FC, RefObject } from 'react';
import { SnackbarProvider } from 'notistack';
import { IconButton } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { FeaturesLoader } from './contexts/features';
import CustomTheme from './CustomTheme';
import AppRouting from './AppRouting';
const App: FC = () => {
const notistackRef: RefObject<any> = React.createRef();
const onClickDismiss = (key: string | number | undefined) => () => {
notistackRef.current.closeSnackbar(key);
};
return (
<CustomTheme>
<SnackbarProvider
maxSnack={3}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
ref={notistackRef}
action={(key) => (
<IconButton onClick={onClickDismiss(key)} size="small">
<CloseIcon />
</IconButton>
)}
>
<FeaturesLoader>
<AppRouting />
</FeaturesLoader>
</SnackbarProvider>
</CustomTheme>
);
};
export default App;

@ -0,0 +1,78 @@
import { FC, useContext, useEffect } from 'react';
import { Navigate, Routes, Route, useLocation } from 'react-router-dom';
import { useSnackbar, VariantType } from 'notistack';
import { Authentication, AuthenticationContext } from './contexts/authentication';
import { FeaturesContext } from './contexts/features';
import { RequireAuthenticated, RequireUnauthenticated } from './components';
import SignIn from './SignIn';
import AuthenticatedRouting from './AuthenticatedRouting';
interface SecurityRedirectProps {
message: string;
variant?: VariantType;
signOut?: boolean;
}
const RootRedirect: FC<SecurityRedirectProps> = ({ message, variant, signOut }) => {
const authenticationContext = useContext(AuthenticationContext);
const { enqueueSnackbar } = useSnackbar();
useEffect(() => {
signOut && authenticationContext.signOut(false);
enqueueSnackbar(message, { variant });
}, [message, variant, signOut, authenticationContext, enqueueSnackbar]);
return (<Navigate to="/" />);
};
export const RemoveTrailingSlashes = () => {
const location = useLocation();
return location.pathname.match('/.*/$') && (
<Navigate
to={{
pathname: location.pathname.replace(/\/+$/, ""),
search: location.search
}}
/>
);
};
const AppRouting: FC = () => {
const { features } = useContext(FeaturesContext);
return (
<Authentication>
<RemoveTrailingSlashes />
<Routes>
<Route
path="/unauthorized"
element={<RootRedirect message="Please log in to continue" signOut />}
/>
<Route
path="/firmwareUpdated"
element={<RootRedirect message="Firmware update successful" variant="success" />}
/>
{features.security &&
<Route
path="/"
element={
<RequireUnauthenticated>
<SignIn />
</RequireUnauthenticated>
}
/>}
<Route
path="/*"
element={
<RequireAuthenticated>
<AuthenticatedRouting />
</RequireAuthenticated>
}
/>
</Routes>
</Authentication>
);
};
export default AppRouting;

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

@ -0,0 +1,40 @@
import { FC } from 'react';
import { CssBaseline } from '@mui/material';
import { createTheme, responsiveFontSizes, ThemeProvider } from '@mui/material/styles';
import { indigo, blueGrey, orange, red, green } from '@mui/material/colors';
import { RequiredChildrenProps } from './utils';
const theme = responsiveFontSizes(
createTheme({
palette: {
background: {
default: "#fafafa"
},
primary: indigo,
secondary: blueGrey,
info: {
main: indigo[500]
},
warning: {
main: orange[500]
},
error: {
main: red[500]
},
success: {
main: green[500]
}
}
})
);
const CustomTheme: FC<RequiredChildrenProps> = ({ children }) => (
<ThemeProvider theme={theme}>
<CssBaseline />
{children}
</ThemeProvider>
);
export default CustomTheme;

112
interface/src/SignIn.tsx Normal file

@ -0,0 +1,112 @@
import { FC, useContext, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { useSnackbar } from 'notistack';
import { Box, Fab, Paper, Typography } from '@mui/material';
import ForwardIcon from '@mui/icons-material/Forward';
import * as AuthenticationApi from './api/authentication';
import { PROJECT_NAME } from './api/env';
import { SignInRequest } from './types';
import { ValidatedTextField } from './components';
import { SIGN_IN_REQUEST_VALIDATOR, validate } from './validators';
import { extractErrorMessage, onEnterCallback, updateValue } from './utils';
import { AuthenticationContext } from './contexts/authentication';
const SignIn: FC = () => {
const authenticationContext = useContext(AuthenticationContext);
const { enqueueSnackbar } = useSnackbar();
const [signInRequest, setSignInRequest] = useState<SignInRequest>({
username: '',
password: ''
});
const [processing, setProcessing] = useState<boolean>(false);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const updateLoginRequestValue = updateValue(setSignInRequest);
const validateAndSignIn = async () => {
setProcessing(true);
try {
await validate(SIGN_IN_REQUEST_VALIDATOR, signInRequest);
signIn();
} catch (errors: any) {
setFieldErrors(errors);
setProcessing(false);
}
};
const signIn = async () => {
try {
const { data: loginResponse } = await AuthenticationApi.signIn(signInRequest);
authenticationContext.signIn(loginResponse.access_token);
} catch (error: any) {
if (error.response?.status === 401) {
enqueueSnackbar("Invalid login details", { variant: "warning" });
} else {
enqueueSnackbar(extractErrorMessage(error, "Unexpected error, please try again"), { variant: "error" });
}
setProcessing(false);
}
};
const submitOnEnter = onEnterCallback(signIn);
return (
<Box
display="flex"
height="100vh"
margin="auto"
padding={2}
justifyContent="center"
flexDirection="column"
maxWidth={(theme) => theme.breakpoints.values.sm}
>
<Paper
sx={(theme) => ({
textAlign: "center",
padding: theme.spacing(2),
paddingTop: "200px",
backgroundImage: 'url("/app/icon.png")',
backgroundRepeat: "no-repeat",
backgroundPosition: "50% " + theme.spacing(2),
backgroundSize: "auto 150px",
width: "100%"
})}
>
<Typography variant="h4">{PROJECT_NAME}</Typography>
<ValidatedTextField
fieldErrors={fieldErrors}
disabled={processing}
name="username"
label="Username"
value={signInRequest.username}
onChange={updateLoginRequestValue}
margin="normal"
variant="outlined"
fullWidth
/>
<ValidatedTextField
fieldErrors={fieldErrors}
disabled={processing}
type="password"
name="password"
label="Password"
value={signInRequest.password}
onChange={updateLoginRequestValue}
onKeyDown={submitOnEnter}
margin="normal"
variant="outlined"
fullWidth
/>
<Fab variant="extended" color="primary" sx={{ mt: 2 }} onClick={validateAndSignIn} disabled={processing}>
<ForwardIcon sx={{ mr: 1 }} />
Sign In
</Fab>
</Paper>
</Box>
);
};
export default SignIn;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@ -0,0 +1,24 @@
import React from 'react';
import { Paper, Typography } from '@mui/material';
import { RequiredChildrenProps } from '../utils';
interface SectionContentProps extends RequiredChildrenProps {
title: string;
titleGutter?: boolean;
}
const SectionContent: React.FC<SectionContentProps> = (props) => {
const { children, title, titleGutter } = props;
return (
<Paper sx={{ p: 2, m: 2 }}>
<Typography variant="h6" gutterBottom={titleGutter}>
{title}
</Typography>
{children}
</Paper>
);
};
export default SectionContent;

@ -0,0 +1,8 @@
export * from './inputs';
export * from './layout';
export * from './loading';
export * from './routing';
export * from './upload';
export { default as SectionContent } from './SectionContent';
export { default as ButtonRow } from './ButtonRow';
export { default as MessageBox } from './MessageBox';

@ -0,0 +1,11 @@
import { FC } from "react";
import { FormControlLabel, FormControlLabelProps } from "@mui/material";
const BlockFormControlLabel: FC<FormControlLabelProps> = (props) => (
<div>
<FormControlLabel {...props} />
</div>
);
export default BlockFormControlLabel;

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

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

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

@ -0,0 +1,44 @@
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 = 260;
const Layout: FC<RequiredChildrenProps> = ({ children }) => {
const [mobileOpen, setMobileOpen] = useState(false);
const [title, setTitle] = useState(PROJECT_NAME);
const { pathname } = useLocation();
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
useEffect(() => setMobileOpen(false), [pathname]);
return (
<LayoutContext.Provider value={{ title, setTitle }}>
<LayoutAppBar title={title} onToggleDrawer={handleDrawerToggle} />
<LayoutDrawer mobileOpen={mobileOpen} onClose={handleDrawerToggle} />
<Box
component="main"
sx={{ marginLeft: { md: `${DRAWER_WIDTH}px` } }}
>
<Toolbar />
{children}
</Box>
</LayoutContext.Provider >
);
};
export default Layout;

@ -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 { FeaturesContext } from '../../contexts/features';
import LayoutAuthMenu from './LayoutAuthMenu';
export const DRAWER_WIDTH = 260;
interface LayoutAppBarProps {
title: string;
onToggleDrawer: () => void;
}
const LayoutAppBar: FC<LayoutAppBarProps> = ({ title, onToggleDrawer }) => {
const { features } = useContext(FeaturesContext);
return (
<AppBar
position="fixed"
sx={{
width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
ml: { md: `${DRAWER_WIDTH}px` },
boxShadow: 'none'
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="open drawer"
edge="start"
onClick={onToggleDrawer}
sx={{ mr: 2, display: { md: 'none' } }}
>
<MenuIcon />
</IconButton>
<Typography variant="h6" noWrap component="div">
{title}
</Typography>
<Box flexGrow={1} />
{features.security && <LayoutAuthMenu />}
</Toolbar>
</AppBar>
);
};
export default LayoutAppBar;

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

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

@ -0,0 +1,47 @@
import { FC, useContext } from 'react';
import { Divider, List } from '@mui/material';
import RoomPreferencesIcon from '@mui/icons-material/RoomPreferences';
import LanIcon from '@mui/icons-material/Lan';
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 { FeaturesContext } from '../../contexts/features';
import ProjectMenu from '../../project/ProjectMenu';
import { AuthenticatedContext } from '../../contexts/authentication';
import LayoutMenuItem from './LayoutMenuItem';
const LayoutMenu: FC = () => {
const { features } = useContext(FeaturesContext);
const authenticatedContext = useContext(AuthenticatedContext);
return (
<>
{features.project && (
<List disablePadding component="nav">
<ProjectMenu />
<Divider />
</List>
)}
<List disablePadding component="nav">
<LayoutMenuItem icon={LanIcon} label="Network" to="/network" />
<LayoutMenuItem icon={RoomPreferencesIcon} label="Intercom" to="/intercom" />
{features.ntp && (
<LayoutMenuItem icon={AccessTimeIcon} label="Network Time" to="/ntp" />
)}
{features.mqtt && (
<LayoutMenuItem icon={DeviceHubIcon} label="MQTT" to="/mqtt" />
)}
{features.security && (
<LayoutMenuItem icon={LockIcon} label="Security" to="/security" disabled={!authenticatedContext.me.admin} />
)}
<LayoutMenuItem icon={SettingsIcon} label="System" to="/system" />
</List>
</>
);
};
export default LayoutMenu;

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

@ -0,0 +1,26 @@
import React, { useRef, useEffect, useContext } from 'react';
export interface LayoutContextValue {
title: string;
setTitle: (title: string) => void;
}
const LayoutContextDefaultValue = {} as LayoutContextValue;
export const LayoutContext = React.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]);
};

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

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

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

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

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

@ -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<RequiredChildrenProps> = ({ children }) => {
const authenticatedContext = useContext(AuthenticatedContext);
return authenticatedContext.me.admin ? <>{children}</> : <Navigate replace to='/' />;
};
export default RequireAdmin;

@ -0,0 +1,26 @@
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<RequiredChildrenProps> = ({ children }) => {
const authenticationContext = useContext(AuthenticationContext);
const location = useLocation();
useEffect(() => {
if (!authenticationContext.me) {
storeLoginRedirect(location);
}
});
return authenticationContext.me ?
<AuthenticatedContext.Provider value={authenticationContext as AuthenticatedContextValue}>
{children}
</AuthenticatedContext.Provider>
:
<Navigate to='/unauthorized' />;
};
export default RequireAuthenticated;

@ -0,0 +1,22 @@
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<RequiredChildrenProps> = ({ children }) => {
const { features } = useContext(FeaturesContext);
const authenticationContext = useContext(AuthenticationContext);
return (
authenticationContext.me ?
<Navigate to={AuthenticationApi.fetchLoginRedirect(features)} />
:
<>{children}</>
);
};
export default RequireUnauthenticated;

@ -0,0 +1,30 @@
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<RouterTabsProps> = ({ value, children }) => {
const navigate = useNavigate();
const theme = useTheme();
const smallDown = useMediaQuery(theme.breakpoints.down('sm'));
const handleTabChange = (event: React.ChangeEvent<{}>, path: string) => {
navigate(path);
};
return (
<Tabs value={value} onChange={handleTabChange} variant={smallDown ? "scrollable" : "fullWidth"}>
{children}
</Tabs>
);
};
export default RouterTabs;

@ -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';

@ -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;
};

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

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

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

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

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

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

@ -0,0 +1,53 @@
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<RequiredChildrenProps> = (props) => {
const [errorMessage, setErrorMessage] = useState<string>();
const [features, setFeatures] = useState<Features>();
const loadFeatures = useCallback(async () => {
try {
const response = await FeaturesApi.readFeatures();
setFeatures(response.data);
} catch (error: any) {
setErrorMessage(extractErrorMessage(error, 'Failed to fetch application details.'));
}
}, []);
useEffect(() => {
loadFeatures();
}, [loadFeatures]);
if (features) {
return (
<FeaturesContext.Provider
value={{
features
}}
>
{props.children}
</FeaturesContext.Provider>
);
}
if (errorMessage) {
return (
<ApplicationError message={errorMessage} />
);
}
return (
<LoadingSpinner height="100vh" />
);
};
export default FeaturesLoader;

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

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

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

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

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

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

@ -0,0 +1,144 @@
import { FC, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { Button, Checkbox } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import * as MqttApi from "../../api/mqtt";
import { MqttSettings } from '../../types';
import { BlockFormControlLabel, ButtonRow, FormLoader, SectionContent, ValidatedPasswordField, ValidatedTextField } from '../../components';
import { MQTT_SETTINGS_VALIDATOR, validate } from '../../validators';
import { numberValue, updateValue, useRest } from '../../utils';
const MqttSettingsForm: FC = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const {
loadData, saving, data, setData, saveData, errorMessage
} = useRest<MqttSettings>({ read: MqttApi.readMqttSettings, update: MqttApi.updateMqttSettings });
const updateFormValue = updateValue(setData);
const content = () => {
if (!data) {
return (<FormLoader onRetry={loadData} errorMessage={errorMessage} />);
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(MQTT_SETTINGS_VALIDATOR, data);
saveData();
} catch (errors: any) {
setFieldErrors(errors);
}
};
return (
<>
<BlockFormControlLabel
control={
<Checkbox
name="enabled"
checked={data.enabled}
onChange={updateFormValue}
/>
}
label="Enable MQTT?"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="host"
label="Host"
fullWidth
variant="outlined"
value={data.host}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="port"
label="Port"
fullWidth
variant="outlined"
value={numberValue(data.port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
name="username"
label="Username"
fullWidth
variant="outlined"
value={data.username}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedPasswordField
name="password"
label="Password"
fullWidth
variant="outlined"
value={data.password}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
name="client_id"
label="Client ID (optional)"
fullWidth
variant="outlined"
value={data.client_id}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="keep_alive"
label="Keep Alive (seconds)"
fullWidth
variant="outlined"
value={numberValue(data.keep_alive)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
<BlockFormControlLabel
control={
<Checkbox
name="clean_session"
checked={data.clean_session}
onChange={updateFormValue}
/>
}
label="Clean Session?"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="max_topic_length"
label="Max Topic Length"
fullWidth
variant="outlined"
value={numberValue(data.max_topic_length)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
<ButtonRow mt={1}>
<Button startIcon={<SaveIcon />} disabled={saving} variant="contained" color="primary" type="submit" onClick={validateAndSubmit}>
Save
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title='MQTT Settings' titleGutter>
{content()}
</SectionContent>
);
};
export default MqttSettingsForm;

@ -0,0 +1,126 @@
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 * as MqttApi from "../../api/mqtt";
import { MqttStatus, MqttDisconnectReason } from "../../types";
import { ButtonRow, FormLoader, SectionContent } from "../../components";
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 mqttStatus = ({ enabled, connected }: MqttStatus) => {
if (!enabled) {
return "Not enabled";
}
if (connected) {
return "Connected";
}
return "Disconnected";
};
export const disconnectReason = ({ disconnect_reason }: MqttStatus) => {
switch (disconnect_reason) {
case MqttDisconnectReason.TCP_DISCONNECTED:
return "TCP disconnected";
case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
return "Unacceptable protocol version";
case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
return "Client ID rejected";
case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
return "Server unavailable";
case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
return "Malformed credentials";
case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
return "Not authorized";
case MqttDisconnectReason.ESP8266_NOT_ENOUGH_SPACE:
return "Device out of memory";
case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
return "Server fingerprint invalid";
default:
return "Unknown";
}
};
const MqttStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<MqttStatus>({ read: MqttApi.readMqttStatus });
const theme = useTheme();
const content = () => {
if (!data) {
return (<FormLoader onRetry={loadData} errorMessage={errorMessage} />);
}
const renderConnectionStatus = () => {
if (data.connected) {
return (
<>
<ListItem>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText primary="Client ID" secondary={data.client_id} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
}
return (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<ReportIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Disconnect Reason" secondary={disconnectReason(data)} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
};
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttStatusHighlight(data, theme) }}>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={mqttStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{data.enabled && renderConnectionStatus()}
</List >
<ButtonRow pt={1}>
<Button startIcon={<RefreshIcon />} variant="contained" color="secondary" onClick={loadData}>
Refresh
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title='MQTT Status' titleGutter>
{content()}
</SectionContent>
);
};
export default MqttStatusForm;

@ -0,0 +1,61 @@
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 { Network } from '../../types';
import { AuthenticatedContext } from '../../contexts/authentication';
import { NetworkConnectionContext } from './NetworkConnectionContext';
import NetworkStatusForm from './NetworkStatusForm';
import NetworkScanner from './NetworkScanner';
import NetworkSettingsForm from './NetworkSettingsForm';
const NetworkConnection: FC = () => {
useLayoutTitle("Network");
const authenticatedContext = useContext(AuthenticatedContext);
const navigate = useNavigate();
const { routerTab } = useRouterTab();
const [selectedNetwork, setSelectedNetwork] = useState<Network>();
const selectNetwork = useCallback((network: Network) => {
setSelectedNetwork(network);
navigate('settings');
}, [navigate]);
const deselectNetwork = useCallback(() => {
setSelectedNetwork(undefined);
}, []);
return (
<NetworkConnectionContext.Provider
value={{
selectedNetwork,
selectNetwork,
deselectNetwork
}}
>
<RouterTabs value={routerTab}>
<Tab value="status" label="Network Status" />
<Tab value="settings" label="Network Settings" disabled={!authenticatedContext.me.admin} />
</RouterTabs>
<Routes>
<Route path="status" element={<NetworkStatusForm />} />
<Route
path="settings"
element={
<RequireAdmin>
<NetworkSettingsForm />
</RequireAdmin>
}
/>
<Route path="/*" element={<Navigate replace to="status" />} />
</Routes>
</NetworkConnectionContext.Provider>
);
};
export default NetworkConnection;

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