admin panel
This commit is contained in:
parent
5f1849caf1
commit
0c74411808
3
.gitignore
vendored
3
.gitignore
vendored
@ -3,3 +3,6 @@
|
|||||||
.vscode/c_cpp_properties.json
|
.vscode/c_cpp_properties.json
|
||||||
.vscode/launch.json
|
.vscode/launch.json
|
||||||
.vscode/ipch
|
.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: | Height: | 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: | Height: | 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: | Height: | 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 Width: | Height: | 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: | Height: | 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