admin panel
This commit is contained in:
parent
5f1849caf1
commit
0c74411808
.gitignore
data/www
frontend2
.eslintrc.cjs.gitignore
.vs
compress-cra.jsonfavicon.icoindex.htmlpackage-lock.jsonpackage.jsonsrc
tsconfig.jsontsconfig.node.jsonvite.config.tsinterface
.env.env.productioncompress-cra.jsonconfig-overrides.jsfavicon.icopackage-lock.jsonpackage.jsonprogmem-generator.js
public
src
App.tsxAppRouting.tsxAuthenticatedRouting.tsxCustomTheme.tsxSignIn.tsx
api
components
ButtonRow.tsxMessageBox.tsxSectionContent.tsxindex.ts
inputs
layout
Layout.tsxLayoutAppBar.tsxLayoutAuthMenu.tsxLayoutDrawer.tsxLayoutMenu.tsxLayoutMenuItem.tsxcontext.tsindex.ts
loading
routing
RequireAdmin.tsxRequireAuthenticated.tsxRequireUnauthenticated.tsxRouterTabs.tsxindex.tsuseRouterTab.ts
upload
contexts
framework
3
.gitignore
vendored
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
BIN
data/www/app/icon.png.gz
Normal file
Binary file not shown.
BIN
data/www/app/manifest.json.gz
Normal file
BIN
data/www/app/manifest.json.gz
Normal file
Binary file not shown.
BIN
data/www/css/roboto.css.gz
Normal file
BIN
data/www/css/roboto.css.gz
Normal file
Binary file not shown.
BIN
data/www/favicon.ico
Normal file
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
BIN
data/www/favicon.ico.gz
Normal file
Binary file not shown.
BIN
data/www/fonts/md.woff2.gz
Normal file
BIN
data/www/fonts/md.woff2.gz
Normal file
Binary file not shown.
BIN
data/www/fonts/re.woff2.gz
Normal file
BIN
data/www/fonts/re.woff2.gz
Normal file
Binary file not shown.
BIN
data/www/index.html.gz
Normal file
BIN
data/www/index.html.gz
Normal file
Binary file not shown.
BIN
data/www/js/179.fa22.js.gz
Normal file
BIN
data/www/js/179.fa22.js.gz
Normal file
Binary file not shown.
14
frontend2/.eslintrc.cjs
Normal file
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
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
BIN
frontend2/.vs/slnx.sqlite
Normal file
Binary file not shown.
BIN
frontend2/.vs/slnx.sqlite-journal
Normal file
BIN
frontend2/.vs/slnx.sqlite-journal
Normal file
Binary file not shown.
20
frontend2/compress-cra.json
Normal file
20
frontend2/compress-cra.json
Normal file
@ -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
BIN
frontend2/favicon.ico
Normal file
Binary file not shown.
After Width: 256px | Height: 256px | Size: 25 KiB |
16
frontend2/index.html
Normal file
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
5837
frontend2/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
frontend2/package.json
Normal file
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
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
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
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
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
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
1
frontend2/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
21
frontend2/tsconfig.json
Normal file
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" }]
|
||||
}
|
10
frontend2/tsconfig.node.json
Normal file
10
frontend2/tsconfig.node.json
Normal file
@ -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
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
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
|
2
interface/.env.production
Normal file
2
interface/.env.production
Normal file
@ -0,0 +1,2 @@
|
||||
# Disable the generation of the sourcemap on the production build to reduce the artefact size
|
||||
GENERATE_SOURCEMAP=false
|
20
interface/compress-cra.json
Normal file
20
interface/compress-cra.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"filetypes": [
|
||||
".html",
|
||||
".js",
|
||||
".css",
|
||||
".svg",
|
||||
".png",
|
||||
".jpg",
|
||||
".ico",
|
||||
".txt",
|
||||
".json",
|
||||
".mp3",
|
||||
".wav",
|
||||
".tff",
|
||||
".woff2"
|
||||
],
|
||||
"algorithms": ["gz"],
|
||||
"directory": "build",
|
||||
"retainUncompressedFiles": false
|
||||
}
|
28
interface/config-overrides.js
Normal file
28
interface/config-overrides.js
Normal file
@ -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
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
28299
interface/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
93
interface/package.json
Normal file
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"
|
||||
]
|
||||
}
|
||||
}
|
123
interface/progmem-generator.js
Normal file
123
interface/progmem-generator.js
Normal file
@ -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;
|
BIN
interface/public/app/icon.png
Normal file
BIN
interface/public/app/icon.png
Normal file
Binary file not shown.
After ![]() (image error) Size: 7.0 KiB |
12
interface/public/app/manifest.json
Normal file
12
interface/public/app/manifest.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name":"ESP8266 React",
|
||||
"icons":[
|
||||
{
|
||||
"src":"/app/icon.png",
|
||||
"sizes":"48x48 72x72 96x96 128x128 256x256"
|
||||
}
|
||||
],
|
||||
"start_url":"/",
|
||||
"display":"fullscreen",
|
||||
"orientation":"any"
|
||||
}
|
22
interface/public/css/roboto.css
Normal file
22
interface/public/css/roboto.css
Normal file
@ -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;
|
||||
}
|
BIN
interface/public/favicon.ico
Normal file
BIN
interface/public/favicon.ico
Normal file
Binary file not shown.
After Width: 256px | Height: 256px | Size: 25 KiB |
BIN
interface/public/fonts/md.woff2
Normal file
BIN
interface/public/fonts/md.woff2
Normal file
Binary file not shown.
BIN
interface/public/fonts/re.woff2
Normal file
BIN
interface/public/fonts/re.woff2
Normal file
Binary file not shown.
16
interface/public/index.html
Normal file
16
interface/public/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, 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
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;
|
78
interface/src/AppRouting.tsx
Normal file
78
interface/src/AppRouting.tsx
Normal file
@ -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;
|
69
interface/src/AuthenticatedRouting.tsx
Normal file
69
interface/src/AuthenticatedRouting.tsx
Normal file
@ -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;
|
40
interface/src/CustomTheme.tsx
Normal file
40
interface/src/CustomTheme.tsx
Normal file
@ -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
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
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);
|
||||
}
|
64
interface/src/api/authentication.ts
Normal file
64
interface/src/api/authentication.ts
Normal file
@ -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();
|
||||
}
|
47
interface/src/api/endpoints.ts
Normal file
47
interface/src/api/endpoints.ts
Normal file
@ -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
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';
|
8
interface/src/api/features.ts
Normal file
8
interface/src/api/features.ts
Normal file
@ -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
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);
|
||||
}
|
24
interface/src/api/network.ts
Normal file
24
interface/src/api/network.ts
Normal file
@ -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
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);
|
||||
}
|
13
interface/src/api/security.ts
Normal file
13
interface/src/api/security.ts
Normal file
@ -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);
|
||||
}
|
||||
|
28
interface/src/api/system.ts
Normal file
28
interface/src/api/system.ts
Normal file
@ -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)
|
||||
);
|
26
interface/src/components/ButtonRow.tsx
Normal file
26
interface/src/components/ButtonRow.tsx
Normal file
@ -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;
|
53
interface/src/components/MessageBox.tsx
Normal file
53
interface/src/components/MessageBox.tsx
Normal file
@ -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;
|
24
interface/src/components/SectionContent.tsx
Normal file
24
interface/src/components/SectionContent.tsx
Normal file
@ -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;
|
8
interface/src/components/index.ts
Normal file
8
interface/src/components/index.ts
Normal file
@ -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';
|
11
interface/src/components/inputs/BlockFormControlLabel.tsx
Normal file
11
interface/src/components/inputs/BlockFormControlLabel.tsx
Normal file
@ -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;
|
36
interface/src/components/inputs/ValidatedPasswordField.tsx
Normal file
36
interface/src/components/inputs/ValidatedPasswordField.tsx
Normal file
@ -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;
|
27
interface/src/components/inputs/ValidatedTextField.tsx
Normal file
27
interface/src/components/inputs/ValidatedTextField.tsx
Normal file
@ -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;
|
3
interface/src/components/inputs/index.ts
Normal file
3
interface/src/components/inputs/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as BlockFormControlLabel } from './BlockFormControlLabel';
|
||||
export { default as ValidatedPasswordField } from './ValidatedPasswordField';
|
||||
export { default as ValidatedTextField } from './ValidatedTextField';
|
44
interface/src/components/layout/Layout.tsx
Normal file
44
interface/src/components/layout/Layout.tsx
Normal file
@ -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;
|
50
interface/src/components/layout/LayoutAppBar.tsx
Normal file
50
interface/src/components/layout/LayoutAppBar.tsx
Normal file
@ -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;
|
81
interface/src/components/layout/LayoutAuthMenu.tsx
Normal file
81
interface/src/components/layout/LayoutAuthMenu.tsx
Normal file
@ -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;
|
78
interface/src/components/layout/LayoutDrawer.tsx
Normal file
78
interface/src/components/layout/LayoutDrawer.tsx
Normal file
@ -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;
|
47
interface/src/components/layout/LayoutMenu.tsx
Normal file
47
interface/src/components/layout/LayoutMenu.tsx
Normal file
@ -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;
|
30
interface/src/components/layout/LayoutMenuItem.tsx
Normal file
30
interface/src/components/layout/LayoutMenuItem.tsx
Normal file
@ -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;
|
26
interface/src/components/layout/context.ts
Normal file
26
interface/src/components/layout/context.ts
Normal file
@ -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]);
|
||||
|
||||
};
|
2
interface/src/components/layout/index.ts
Normal file
2
interface/src/components/layout/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './context';
|
||||
export { default as Layout } from './Layout';
|
48
interface/src/components/loading/ApplicationError.tsx
Normal file
48
interface/src/components/loading/ApplicationError.tsx
Normal file
@ -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;
|
39
interface/src/components/loading/FormLoader.tsx
Normal file
39
interface/src/components/loading/FormLoader.tsx
Normal file
@ -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;
|
24
interface/src/components/loading/LoadingSpinner.tsx
Normal file
24
interface/src/components/loading/LoadingSpinner.tsx
Normal file
@ -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…
|
||||
</Typography>
|
||||
</Box>
|
||||
);
|
||||
|
||||
export default LoadingSpinner;
|
3
interface/src/components/loading/index.ts
Normal file
3
interface/src/components/loading/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { default as ApplicationError } from './ApplicationError';
|
||||
export { default as LoadingSpinner } from './LoadingSpinner';
|
||||
export { default as FormLoader } from './FormLoader';
|
12
interface/src/components/routing/RequireAdmin.tsx
Normal file
12
interface/src/components/routing/RequireAdmin.tsx
Normal file
@ -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;
|
26
interface/src/components/routing/RequireAuthenticated.tsx
Normal file
26
interface/src/components/routing/RequireAuthenticated.tsx
Normal file
@ -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;
|
22
interface/src/components/routing/RequireUnauthenticated.tsx
Normal file
22
interface/src/components/routing/RequireUnauthenticated.tsx
Normal file
@ -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;
|
30
interface/src/components/routing/RouterTabs.tsx
Normal file
30
interface/src/components/routing/RouterTabs.tsx
Normal file
@ -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;
|
6
interface/src/components/routing/index.ts
Normal file
6
interface/src/components/routing/index.ts
Normal file
@ -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';
|
9
interface/src/components/routing/useRouterTab.ts
Normal file
9
interface/src/components/routing/useRouterTab.ts
Normal file
@ -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;
|
||||
};
|
88
interface/src/components/upload/SingleUpload.tsx
Normal file
88
interface/src/components/upload/SingleUpload.tsx
Normal file
@ -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;
|
2
interface/src/components/upload/index.ts
Normal file
2
interface/src/components/upload/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { default as SingleUpload } from './SingleUpload';
|
||||
export { default as useFileUpload } from './useFileUpload';
|
59
interface/src/components/upload/useFileUpload.ts
Normal file
59
interface/src/components/upload/useFileUpload.ts
Normal file
@ -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;
|
88
interface/src/contexts/authentication/Authentication.tsx
Normal file
88
interface/src/contexts/authentication/Authentication.tsx
Normal file
@ -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;
|
23
interface/src/contexts/authentication/context.ts
Normal file
23
interface/src/contexts/authentication/context.ts
Normal file
@ -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
|
||||
);
|
2
interface/src/contexts/authentication/index.ts
Normal file
2
interface/src/contexts/authentication/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './context';
|
||||
export { default as Authentication } from './Authentication';
|
53
interface/src/contexts/features/FeaturesLoader.tsx
Normal file
53
interface/src/contexts/features/FeaturesLoader.tsx
Normal file
@ -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;
|
12
interface/src/contexts/features/context.ts
Normal file
12
interface/src/contexts/features/context.ts
Normal file
@ -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
|
||||
);
|
2
interface/src/contexts/features/index.ts
Normal file
2
interface/src/contexts/features/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './context';
|
||||
export { default as FeaturesLoader } from './FeaturesLoader';
|
171
interface/src/framework/ap/APSettingsForm.tsx
Normal file
171
interface/src/framework/ap/APSettingsForm.tsx
Normal file
@ -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…"
|
||||
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;
|
105
interface/src/framework/ap/APStatusForm.tsx
Normal file
105
interface/src/framework/ap/APStatusForm.tsx
Normal file
@ -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;
|
41
interface/src/framework/ap/AccessPoint.tsx
Normal file
41
interface/src/framework/ap/AccessPoint.tsx
Normal file
@ -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;
|
41
interface/src/framework/mqtt/Mqtt.tsx
Normal file
41
interface/src/framework/mqtt/Mqtt.tsx
Normal file
@ -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;
|
144
interface/src/framework/mqtt/MqttSettingsForm.tsx
Normal file
144
interface/src/framework/mqtt/MqttSettingsForm.tsx
Normal file
@ -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;
|
126
interface/src/framework/mqtt/MqttStatusForm.tsx
Normal file
126
interface/src/framework/mqtt/MqttStatusForm.tsx
Normal file
@ -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;
|
61
interface/src/framework/network/NetworkConnection.tsx
Normal file
61
interface/src/framework/network/NetworkConnection.tsx
Normal file
@ -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
Loading…
x
Reference in New Issue
Block a user