Merge remote-tracking branch 'origin/v3.4' into dev

This commit is contained in:
proddy
2022-01-23 17:56:52 +01:00
parent 02e2b51814
commit 77e1898512
538 changed files with 32282 additions and 38655 deletions

View File

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

View File

@@ -0,0 +1,104 @@
import { FC } from 'react';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, Theme, useTheme } from '@mui/material';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ComputerIcon from '@mui/icons-material/Computer';
import RefreshIcon from '@mui/icons-material/Refresh';
import * as APApi from '../../api/ap';
import { APNetworkStatus, APStatus } from '../../types';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { useRest } from '../../utils';
export const apStatusHighlight = ({ status }: APStatus, theme: Theme) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return theme.palette.success.main;
case APNetworkStatus.INACTIVE:
return theme.palette.info.main;
case APNetworkStatus.LINGERING:
return theme.palette.warning.main;
default:
return theme.palette.warning.main;
}
};
export const apStatus = ({ status }: APStatus) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return 'Active';
case APNetworkStatus.INACTIVE:
return 'Inactive';
case APNetworkStatus.LINGERING:
return 'Lingering until idle';
default:
return 'Unknown';
}
};
const APStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<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>
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={loadData}>
Refresh
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="Access Point Status" titleGutter>
{content()}
</SectionContent>
);
};
export default APStatusForm;

View File

@@ -0,0 +1,40 @@
import { FC, useContext } from 'react';
import { Navigate, Routes, Route } from 'react-router-dom';
import { Tab } from '@mui/material';
import { AuthenticatedContext } from '../../contexts/authentication';
import APStatusForm from './APStatusForm';
import APSettingsForm from './APSettingsForm';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
const AccessPoint: FC = () => {
useLayoutTitle('Access Point');
const authenticatedContext = useContext(AuthenticatedContext);
const { routerTab } = useRouterTab();
return (
<>
<RouterTabs value={routerTab}>
<Tab value="status" label="Access Point Status" />
<Tab value="settings" label="Access Point 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;

View File

@@ -0,0 +1,40 @@
import React, { FC, useContext } from 'react';
import { Navigate, Route, Routes } from 'react-router-dom';
import { Tab } from '@mui/material';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
import { AuthenticatedContext } from '../../contexts/authentication';
import MqttStatusForm from './MqttStatusForm';
import MqttSettingsForm from './MqttSettingsForm';
const Mqtt: FC = () => {
useLayoutTitle('MQTT');
const authenticatedContext = useContext(AuthenticatedContext);
const { routerTab } = useRouterTab();
return (
<>
<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;

View File

@@ -0,0 +1,315 @@
import { FC, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { Button, Checkbox, MenuItem, Grid, Typography } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import { MQTT_SETTINGS_VALIDATOR, validate } from '../../validators';
import {
BlockFormControlLabel,
ButtonRow,
FormLoader,
SectionContent,
ValidatedPasswordField,
ValidatedTextField
} from '../../components';
import { MqttSettings } from '../../types';
import { numberValue, updateValue, useRest } from '../../utils';
import * as MqttApi from '../../api/mqtt';
const MqttSettingsForm: FC = () => {
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<MqttSettings>({
read: MqttApi.readMqttSettings,
update: MqttApi.updateMqttSettings
});
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
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"
/>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="host"
label="Host"
fullWidth
variant="outlined"
value={data.host}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="port"
label="Port"
fullWidth
variant="outlined"
value={numberValue(data.port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="base"
label="Bsse"
fullWidth
variant="outlined"
value={data.base}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={6}>
<ValidatedTextField
name="client_id"
label="Client ID (optional)"
fullWidth
variant="outlined"
value={data.client_id}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={6}>
<ValidatedTextField
name="username"
label="Username"
fullWidth
variant="outlined"
value={data.username}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={6}>
<ValidatedPasswordField
name="password"
label="Password"
fullWidth
variant="outlined"
value={data.password}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={6}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="keep_alive"
label="Keep Alive (seconds)"
fullWidth
variant="outlined"
value={numberValue(data.keep_alive)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={6}>
<ValidatedTextField
name="mqtt_qos"
label="QoS"
value={data.mqtt_qos}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={0}>0 (default)</MenuItem>
<MenuItem value={1}>1</MenuItem>
<MenuItem value={2}>2</MenuItem>
</ValidatedTextField>
</Grid>
</Grid>
<BlockFormControlLabel
control={<Checkbox name="clean_session" checked={data.clean_session} onChange={updateFormValue} />}
label="Set Clean Session"
/>
<BlockFormControlLabel
control={<Checkbox name="mqtt_retain" checked={data.mqtt_retain} onChange={updateFormValue} />}
label="Always use Retain Flag"
/>
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
Formatting
</Typography>
<ValidatedTextField
name="nested_format"
label="Topic/Payload Format"
value={data.nested_format}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={1}>Nested in a single topic</MenuItem>
<MenuItem value={2}>As individual topics</MenuItem>
</ValidatedTextField>
<BlockFormControlLabel
control={<Checkbox name="send_response" checked={data.send_response} onChange={updateFormValue} />}
label="Publish command output to a 'response' topic"
/>
<BlockFormControlLabel
control={<Checkbox name="publish_single" checked={data.publish_single} onChange={updateFormValue} />}
label="Publish single value topics on change"
/>
<Grid item>
<BlockFormControlLabel
control={<Checkbox name="ha_enabled" checked={data.ha_enabled} onChange={updateFormValue} />}
label="Enable MQTT Discovery (for Home Assistant, Domoticz)"
/>
</Grid>
{data.ha_enabled && (
<Grid item xs={6}>
<ValidatedTextField
name="discovery_prefix"
label="Prefix for the Discovery topic"
fullWidth
variant="outlined"
value={data.discovery_prefix}
onChange={updateFormValue}
margin="normal"
/>
</Grid>
)}
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
Publish Intervals (in seconds, 0=automatic)
</Typography>
<Grid container spacing={1} direction="row" justifyContent="flex-start" alignItems="flex-start">
<Grid item xs={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_boiler"
label="Boilers and Heat Pumps"
fullWidth
variant="outlined"
value={numberValue(data.publish_time_boiler)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_thermostat"
label="Thermostats"
fullWidth
variant="outlined"
value={numberValue(data.publish_time_thermostat)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_solar"
label="Solar Modules"
fullWidth
variant="outlined"
value={numberValue(data.publish_time_solar)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_mixer"
label="Mixer Modules"
fullWidth
variant="outlined"
value={numberValue(data.publish_time_mixer)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_sensor"
label="Temperature Sensors"
fullWidth
variant="outlined"
value={numberValue(data.publish_time_sensor)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
<Grid item xs={4}>
<ValidatedTextField
fieldErrors={fieldErrors}
name="publish_time_other"
label="Default"
fullWidth
variant="outlined"
value={numberValue(data.publish_time_other)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
</Grid>
</Grid>
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
Save
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="MQTT Settings" titleGutter>
{content()}
</SectionContent>
);
};
export default MqttSettingsForm;

View File

@@ -0,0 +1,142 @@
import { FC } from 'react';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, Theme, useTheme } from '@mui/material';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import RefreshIcon from '@mui/icons-material/Refresh';
import ReportIcon from '@mui/icons-material/Report';
import SpeakerNotesOffIcon from '@mui/icons-material/SpeakerNotesOff';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { MqttStatus, MqttDisconnectReason } from '../../types';
import * as MqttApi from '../../api/mqtt';
import { useRest } from '../../utils';
export const mqttStatusHighlight = ({ enabled, connected }: MqttStatus, theme: Theme) => {
if (!enabled) {
return theme.palette.info.main;
}
if (connected) {
return theme.palette.success.main;
}
return theme.palette.error.main;
};
export const mqttPublishHighlight = ({ mqtt_fails }: MqttStatus, theme: Theme) => {
if (mqtt_fails === 0) return theme.palette.success.main;
if (mqtt_fails < 10) return theme.palette.warning.main;
return theme.palette.error.main;
};
export const mqttStatus = ({ enabled, connected }: MqttStatus) => {
if (!enabled) {
return 'Not enabled';
}
if (connected) {
return 'Connected';
}
return 'Disconnected';
};
export const disconnectReason = ({ disconnect_reason }: MqttStatus) => {
switch (disconnect_reason) {
case MqttDisconnectReason.TCP_DISCONNECTED:
return 'TCP disconnected';
case MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION:
return 'Unacceptable protocol version';
case MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED:
return 'Client ID rejected';
case MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE:
return 'Server unavailable';
case MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS:
return 'Malformed credentials';
case MqttDisconnectReason.MQTT_NOT_AUTHORIZED:
return 'Not authorized';
case MqttDisconnectReason.ESP8266_NOT_ENOUGH_SPACE:
return 'Device out of memory';
case MqttDisconnectReason.TLS_BAD_FINGERPRINT:
return 'Server fingerprint invalid';
default:
return 'Unknown';
}
};
const MqttStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<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" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttPublishHighlight(data, theme) }}>
<SpeakerNotesOffIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="MQTT Publish Errors" secondary={data.mqtt_fails} />
</ListItem>
</>
);
}
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>
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={loadData}>
Refresh
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="MQTT Status" titleGutter>
{content()}
</SectionContent>
);
};
export default MqttStatusForm;

View File

@@ -0,0 +1,72 @@
import React, { FC, useCallback, useContext, useState } from 'react';
import { Navigate, Routes, Route, useNavigate } from 'react-router-dom';
import { Tab } from '@mui/material';
import { RequireAdmin, RouterTabs, useLayoutTitle, useRouterTab } from '../../components';
import { WiFiNetwork } from '../../types';
import { AuthenticatedContext } from '../../contexts/authentication';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import NetworkStatusForm from './NetworkStatusForm';
import WiFiNetworkScanner from './WiFiNetworkScanner';
import NetworkSettingsForm from './NetworkSettingsForm';
const NetworkConnection: FC = () => {
useLayoutTitle('Network Connection');
const authenticatedContext = useContext(AuthenticatedContext);
const navigate = useNavigate();
const { routerTab } = useRouterTab();
const [selectedNetwork, setSelectedNetwork] = useState<WiFiNetwork>();
const selectNetwork = useCallback(
(network: WiFiNetwork) => {
setSelectedNetwork(network);
navigate('settings');
},
[navigate]
);
const deselectNetwork = useCallback(() => {
setSelectedNetwork(undefined);
}, []);
return (
<WiFiConnectionContext.Provider
value={{
selectedNetwork,
selectNetwork,
deselectNetwork
}}
>
<RouterTabs value={routerTab}>
<Tab value="status" label="Network Status" />
<Tab value="scan" label="Scan WiFi Networks" disabled={!authenticatedContext.me.admin} />
<Tab value="settings" label="Network Settings" disabled={!authenticatedContext.me.admin} />
</RouterTabs>
<Routes>
<Route path="status" element={<NetworkStatusForm />} />
<Route
path="scan"
element={
<RequireAdmin>
<WiFiNetworkScanner />
</RequireAdmin>
}
/>
<Route
path="settings"
element={
<RequireAdmin>
<NetworkSettingsForm />
</RequireAdmin>
}
/>
<Route path="/*" element={<Navigate replace to="status" />} />
</Routes>
</WiFiConnectionContext.Provider>
);
};
export default NetworkConnection;

View File

@@ -0,0 +1,257 @@
import { FC, useContext, useEffect, useState } from 'react';
import {
Avatar,
Button,
Checkbox,
IconButton,
List,
ListItem,
ListItemAvatar,
ListItemSecondaryAction,
ListItemText,
Typography
} from '@mui/material';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import DeleteIcon from '@mui/icons-material/Delete';
import SaveIcon from '@mui/icons-material/Save';
import LockIcon from '@mui/icons-material/Lock';
import {
BlockFormControlLabel,
ButtonRow,
FormLoader,
SectionContent,
ValidatedPasswordField,
ValidatedTextField
} from '../../components';
import { NetworkSettings } from '../../types';
import * as NetworkApi from '../../api/network';
import { numberValue, updateValue, useRest } from '../../utils';
import { WiFiConnectionContext } from './WiFiConnectionContext';
import { isNetworkOpen, networkSecurityMode } from './WiFiNetworkSelector';
import { ValidateFieldsError } from 'async-validator';
import { validate } from '../../validators';
import { createNetworkSettingsValidator } from '../../validators/network';
const WiFiSettingsForm: FC = () => {
const { selectedNetwork, deselectNetwork } = useContext(WiFiConnectionContext);
const [initialized, setInitialized] = useState(false);
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<NetworkSettings>({
read: NetworkApi.readNetworkSettings,
update: NetworkApi.updateNetworkSettings
});
useEffect(() => {
if (!initialized && data) {
if (selectedNetwork) {
setData({
ssid: selectedNetwork.ssid,
password: '',
hostname: data?.hostname,
static_ip_config: false,
enableIPv6: false,
bandwidth20: false,
tx_power: 20,
nosleep: false
});
}
setInitialized(true);
}
}, [initialized, setInitialized, data, setData, selectedNetwork]);
const updateFormValue = updateValue(setData);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
useEffect(() => deselectNetwork, [deselectNetwork]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(createNetworkSettingsValidator(data), data);
saveData();
} catch (errors: any) {
setFieldErrors(errors);
}
};
return (
<>
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
WiFi
</Typography>
{selectedNetwork ? (
<List>
<ListItem>
<ListItemAvatar>
<Avatar>{isNetworkOpen(selectedNetwork) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={selectedNetwork.ssid}
secondary={'Security: ' + networkSecurityMode(selectedNetwork) + ', Ch: ' + selectedNetwork.channel}
/>
<ListItemSecondaryAction>
<IconButton aria-label="Manual Config" onClick={deselectNetwork}>
<DeleteIcon />
</IconButton>
</ListItemSecondaryAction>
</ListItem>
</List>
) : (
<ValidatedTextField
fieldErrors={fieldErrors}
name="ssid"
label="SSID (leave blank to disable WiFi)"
fullWidth
variant="outlined"
value={data.ssid}
onChange={updateFormValue}
margin="normal"
/>
)}
{(!selectedNetwork || !isNetworkOpen(selectedNetwork)) && (
<ValidatedPasswordField
fieldErrors={fieldErrors}
name="password"
label="Password"
fullWidth
variant="outlined"
value={data.password}
onChange={updateFormValue}
margin="normal"
/>
)}
<ValidatedTextField
fieldErrors={fieldErrors}
name="tx_power"
label="WiFi Tx Power (dBm)"
fullWidth
variant="outlined"
value={numberValue(data.tx_power)}
onChange={updateFormValue}
type="number"
margin="normal"
/>
<BlockFormControlLabel
control={<Checkbox name="nosleep" checked={data.nosleep} onChange={updateFormValue} />}
label="Disable WiFi Sleep Mode"
/>
<BlockFormControlLabel
control={<Checkbox name="bandwidth20" checked={data.bandwidth20} onChange={updateFormValue} />}
label="Use Lower WiFi Bandwidth"
/>
<Typography sx={{ pt: 2 }} variant="h6" color="primary">
General
</Typography>
<ValidatedTextField
fieldErrors={fieldErrors}
name="hostname"
label="Hostname"
fullWidth
variant="outlined"
value={data.hostname}
onChange={updateFormValue}
margin="normal"
/>
<BlockFormControlLabel
control={<Checkbox name="enableIPv6" checked={data.enableIPv6} onChange={updateFormValue} />}
label="Enable IPv6 support"
/>
<BlockFormControlLabel
control={<Checkbox name="static_ip_config" checked={data.static_ip_config} onChange={updateFormValue} />}
label="Use Fixed IP address"
/>
{data.static_ip_config && (
<>
<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"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="dns_ip_1"
label="DNS IP #1"
fullWidth
variant="outlined"
value={data.dns_ip_1}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="dns_ip_2"
label="DNS IP #2"
fullWidth
variant="outlined"
value={data.dns_ip_2}
onChange={updateFormValue}
margin="normal"
/>
</>
)}
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
Save
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="Network Settings" titleGutter>
{content()}
</SectionContent>
);
};
export default WiFiSettingsForm;

View File

@@ -0,0 +1,179 @@
import { FC } from 'react';
import { Avatar, Button, Divider, List, ListItem, ListItemAvatar, ListItemText, Theme, useTheme } from '@mui/material';
import SettingsInputComponentIcon from '@mui/icons-material/SettingsInputComponent';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import WifiIcon from '@mui/icons-material/Wifi';
import DnsIcon from '@mui/icons-material/Dns';
import RefreshIcon from '@mui/icons-material/Refresh';
import RouterIcon from '@mui/icons-material/Router';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { NetworkConnectionStatus, NetworkStatus } from '../../types';
import * as NetworkApi from '../../api/network';
import { useRest } from '../../utils';
const isConnected = ({ status }: NetworkStatus) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const networkStatusHighlight = ({ status }: NetworkStatus, theme: Theme) => {
switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return theme.palette.info.main;
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return theme.palette.success.main;
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return theme.palette.error.main;
default:
return theme.palette.warning.main;
}
};
const networkStatus = ({ status }: NetworkStatus) => {
switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return 'Inactive';
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return 'Idle';
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return 'Connected (WiFi)';
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return 'Connected (Wired)';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return 'Connection Failed';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return 'Connection Lost';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return 'Disconnected';
default:
return 'Unknown';
}
};
export const isWiFi = ({ status }: NetworkStatus) => status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatus) => status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const dnsServers = ({ dns_ip_1, dns_ip_2 }: NetworkStatus) => {
if (!dns_ip_1) {
return 'none';
}
return dns_ip_1 + (dns_ip_2 ? ',' + dns_ip_2 : '');
};
const IPs = (status: NetworkStatus) => {
if (!status.local_ipv6 || status.local_ipv6 === '0000:0000:0000:0000:0000:0000:0000:0000') {
return status.local_ip;
}
if (!status.local_ip || status.local_ip === '0.0.0.0') {
return status.local_ipv6;
}
return status.local_ip + ', ' + status.local_ipv6;
};
const NetworkStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<NetworkStatus>({ read: NetworkApi.readNetworkStatus });
const theme = useTheme();
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
{isWiFi(data) && <WifiIcon />}
{isEthernet(data) && <RouterIcon />}
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={networkStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{isWiFi(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="SSID" secondary={data.ssid} />
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
{isConnected(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>IP</Avatar>
</ListItemAvatar>
<ListItemText primary="IP Address" secondary={IPs(data)} />
</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>#</Avatar>
</ListItemAvatar>
<ListItemText primary="Subnet Mask" secondary={data.subnet_mask} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<SettingsInputComponentIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Gateway IP" secondary={data.gateway_ip || 'none'} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<DnsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="DNS Server IP" secondary={dnsServers(data)} />
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
</List>
<ButtonRow>
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={loadData}>
Refresh
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="Network Status" titleGutter>
{content()}
</SectionContent>
);
};
export default NetworkStatusForm;

View File

@@ -0,0 +1,11 @@
import { createContext } from 'react';
import { WiFiNetwork } from '../../types';
export interface WiFiConnectionContextValue {
selectedNetwork?: WiFiNetwork;
selectNetwork: (network: WiFiNetwork) => void;
deselectNetwork: () => void;
}
const WiFiConnectionContextDefaultValue = {} as WiFiConnectionContextValue;
export const WiFiConnectionContext = createContext(WiFiConnectionContextDefaultValue);

View File

@@ -0,0 +1,101 @@
import { useEffect, FC, useState, useCallback, useRef } from 'react';
import { useSnackbar } from 'notistack';
import { Button } from '@mui/material';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import * as NetworkApi from '../../api/network';
import { WiFiNetwork, WiFiNetworkList } from '../../types';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { extractErrorMessage } from '../../utils';
import WiFiNetworkSelector from './WiFiNetworkSelector';
const NUM_POLLS = 10;
const POLLING_FREQUENCY = 500;
const compareNetworks = (network1: WiFiNetwork, network2: WiFiNetwork) => {
if (network1.rssi < network2.rssi) return 1;
if (network1.rssi > network2.rssi) return -1;
return 0;
};
const WiFiNetworkScanner: FC = () => {
const { enqueueSnackbar } = useSnackbar();
const pollCount = useRef(0);
const [networkList, setNetworkList] = useState<WiFiNetworkList>();
const [errorMessage, setErrorMessage] = useState<string>();
const finishedWithError = useCallback(
(message: string) => {
enqueueSnackbar(message, { variant: 'error' });
setNetworkList(undefined);
setErrorMessage(message);
},
[enqueueSnackbar]
);
const pollNetworkList = useCallback(async () => {
try {
const response = await NetworkApi.listNetworks();
if (response.status === 202) {
const completedPollCount = pollCount.current + 1;
if (completedPollCount < NUM_POLLS) {
pollCount.current = completedPollCount;
setTimeout(pollNetworkList, POLLING_FREQUENCY);
} else {
finishedWithError('Device did not return network list in timely manner');
}
} else {
const newNetworkList = response.data;
newNetworkList.networks.sort(compareNetworks);
setNetworkList(newNetworkList);
}
} catch (error: any) {
finishedWithError(extractErrorMessage(error, 'Problem listing WiFi networks'));
}
}, [finishedWithError]);
const startNetworkScan = useCallback(async () => {
pollCount.current = 0;
setNetworkList(undefined);
setErrorMessage(undefined);
try {
await NetworkApi.scanNetworks();
setTimeout(pollNetworkList, POLLING_FREQUENCY);
} catch (error: any) {
finishedWithError(extractErrorMessage(error, 'Problem scanning for WiFi networks'));
}
}, [finishedWithError, pollNetworkList]);
useEffect(() => {
startNetworkScan();
}, [startNetworkScan]);
const renderNetworkScanner = () => {
if (!networkList) {
return <FormLoader message="Scanning&hellip;" errorMessage={errorMessage} />;
}
return <WiFiNetworkSelector networkList={networkList} />;
};
return (
<SectionContent title="Network Scanner">
{renderNetworkScanner()}
<ButtonRow>
<Button
startIcon={<PermScanWifiIcon />}
variant="outlined"
color="secondary"
onClick={startNetworkScan}
disabled={!errorMessage && !networkList}
>
Scan again&hellip;
</Button>
</ButtonRow>
</SectionContent>
);
};
export default WiFiNetworkScanner;

View File

@@ -0,0 +1,69 @@
import { FC, useContext } from 'react';
import { Avatar, Badge, List, ListItem, ListItemAvatar, ListItemIcon, ListItemText } from '@mui/material';
import LockOpenIcon from '@mui/icons-material/LockOpen';
import LockIcon from '@mui/icons-material/Lock';
import WifiIcon from '@mui/icons-material/Wifi';
import { MessageBox } from '../../components';
import { WiFiEncryptionType, WiFiNetwork, WiFiNetworkList } from '../../types';
import { WiFiConnectionContext } from './WiFiConnectionContext';
interface WiFiNetworkSelectorProps {
networkList: WiFiNetworkList;
}
export const isNetworkOpen = ({ encryption_type }: WiFiNetwork) =>
encryption_type === WiFiEncryptionType.WIFI_AUTH_OPEN;
export const networkSecurityMode = ({ encryption_type }: WiFiNetwork) => {
switch (encryption_type) {
case WiFiEncryptionType.WIFI_AUTH_WEP:
case WiFiEncryptionType.WIFI_AUTH_WEP_PSK:
return 'WEP';
case WiFiEncryptionType.WIFI_AUTH_WEP2_PSK:
return 'WEP2';
case WiFiEncryptionType.WIFI_AUTH_WPA_WPA2_PSK:
return 'WPA/WEP2';
case WiFiEncryptionType.WIFI_AUTH_WPA2_ENTERPRISE:
return 'WEP2 Enterprise';
case WiFiEncryptionType.WIFI_AUTH_OPEN:
return 'None';
default:
return 'Unknown';
}
};
const WiFiNetworkSelector: FC<WiFiNetworkSelectorProps> = ({ networkList }) => {
const wifiConnectionContext = useContext(WiFiConnectionContext);
const renderNetwork = (network: WiFiNetwork) => {
return (
<ListItem key={network.bssid} button onClick={() => wifiConnectionContext.selectNetwork(network)}>
<ListItemAvatar>
<Avatar>{isNetworkOpen(network) ? <LockOpenIcon /> : <LockIcon />}</Avatar>
</ListItemAvatar>
<ListItemText
primary={network.ssid}
secondary={'Security: ' + networkSecurityMode(network) + ', Ch: ' + network.channel}
/>
<ListItemIcon>
<Badge badgeContent={network.rssi + 'db'}>
<WifiIcon />
</Badge>
</ListItemIcon>
</ListItem>
);
};
if (networkList.networks.length === 0) {
return <MessageBox mt={2} mb={1} message="No WiFi networks found" level="info" />;
}
return <List>{networkList.networks.map(renderNetwork)}</List>;
};
export default WiFiNetworkSelector;

View File

@@ -0,0 +1,101 @@
import { FC, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { Button, Checkbox, MenuItem } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import { validate } from '../../validators';
import { BlockFormControlLabel, ButtonRow, FormLoader, SectionContent, ValidatedTextField } from '../../components';
import { NTPSettings } from '../../types';
import { updateValue, useRest } from '../../utils';
import * as NTPApi from '../../api/ntp';
import { selectedTimeZone, timeZoneSelectItems, TIME_ZONES } from './TZ';
import { NTP_SETTINGS_VALIDATOR } from '../../validators/ntp';
const NTPSettingsForm: FC = () => {
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<NTPSettings>({
read: NTPApi.readNTPSettings,
update: NTPApi.updateNTPSettings
});
const updateFormValue = updateValue(setData);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(NTP_SETTINGS_VALIDATOR, data);
saveData();
} catch (errors: any) {
setFieldErrors(errors);
}
};
const changeTimeZone = (event: React.ChangeEvent<HTMLInputElement>) => {
setData({
...data,
tz_label: event.target.value,
tz_format: TIME_ZONES[event.target.value]
});
};
return (
<>
<BlockFormControlLabel
control={<Checkbox name="enabled" checked={data.enabled} onChange={updateFormValue} />}
label="Enable NTP"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="server"
label="Server"
fullWidth
variant="outlined"
value={data.server}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="tz_label"
label="Time zone"
fullWidth
variant="outlined"
value={selectedTimeZone(data.tz_label, data.tz_format)}
onChange={changeTimeZone}
margin="normal"
select
>
<MenuItem disabled>Time zone...</MenuItem>
{timeZoneSelectItems()}
</ValidatedTextField>
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
Save
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="NTP Settings" titleGutter>
{content()}
</SectionContent>
);
};
export default NTPSettingsForm;

View File

@@ -0,0 +1,218 @@
import { FC, useContext, useState } from 'react';
import { useSnackbar } from 'notistack';
import {
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
TextField,
Theme,
useTheme
} from '@mui/material';
import RefreshIcon from '@mui/icons-material/Refresh';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
import UpdateIcon from '@mui/icons-material/Update';
import DnsIcon from '@mui/icons-material/Dns';
import AvTimerIcon from '@mui/icons-material/AvTimer';
import CancelIcon from '@mui/icons-material/Cancel';
import * as NTPApi from '../../api/ntp';
import { NTPStatus, NTPSyncStatus } from '../../types';
import { ButtonRow, FormLoader, SectionContent } from '../../components';
import { extractErrorMessage, formatDateTime, formatDuration, formatLocalDateTime, useRest } from '../../utils';
import { AuthenticatedContext } from '../../contexts/authentication';
export const isNtpActive = ({ status }: NTPStatus) => status === NTPSyncStatus.NTP_ACTIVE;
export const ntpStatusHighlight = ({ status }: NTPStatus, theme: Theme) => {
switch (status) {
case NTPSyncStatus.NTP_INACTIVE:
return theme.palette.info.main;
case NTPSyncStatus.NTP_ACTIVE:
return theme.palette.success.main;
default:
return theme.palette.error.main;
}
};
export const ntpStatus = ({ status }: NTPStatus) => {
switch (status) {
case NTPSyncStatus.NTP_INACTIVE:
return 'Inactive';
case NTPSyncStatus.NTP_ACTIVE:
return 'Active';
default:
return 'Unknown';
}
};
const NTPStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<NTPStatus>({ read: NTPApi.readNTPStatus });
const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const { enqueueSnackbar } = useSnackbar();
const { me } = useContext(AuthenticatedContext);
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) => setLocalTime(event.target.value);
const openSetTime = () => {
setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true);
};
const theme = useTheme();
const configureTime = async () => {
setProcessing(true);
try {
await NTPApi.updateTime({
local_time: formatLocalDateTime(new Date(localTime))
});
enqueueSnackbar('Time set', { variant: 'success' });
setSettingTime(false);
loadData();
} catch (error: any) {
enqueueSnackbar(extractErrorMessage(error, 'Problem updating time'), { variant: 'error' });
} finally {
setProcessing(false);
}
};
const renderSetTimeDialog = () => {
return (
<Dialog open={settingTime} onClose={() => setSettingTime(false)}>
<DialogTitle>Set Time</DialogTitle>
<DialogContent dividers>
<Box mb={2}>Enter local date and time below to set the device's time.</Box>
<TextField
label="Local Time"
type="datetime-local"
value={localTime}
onChange={updateLocalTime}
disabled={processing}
variant="outlined"
fullWidth
InputLabelProps={{
shrink: true
}}
/>
</DialogContent>
<DialogActions>
<Button startIcon={<CancelIcon />} variant="outlined" onClick={() => setSettingTime(false)} color="secondary">
Cancel
</Button>
<Button
startIcon={<AccessTimeIcon />}
variant="outlined"
onClick={configureTime}
disabled={processing}
color="primary"
autoFocus
>
Set Time
</Button>
</DialogActions>
</Dialog>
);
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: ntpStatusHighlight(data, theme) }}>
<UpdateIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={ntpStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{isNtpActive(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<DnsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="NTP Server" secondary={data.server} />
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>
<AccessTimeIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Local Time" secondary={formatDateTime(data.local_time)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<SwapVerticalCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="UTC Time" secondary={formatDateTime(data.utc_time)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<AvTimerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Uptime" secondary={formatDuration(data.uptime)} />
</ListItem>
<Divider variant="inset" component="li" />
</List>
<Box display="flex" flexWrap="wrap">
<Box flexGrow={1}>
<ButtonRow>
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={loadData}>
Refresh
</Button>
</ButtonRow>
</Box>
{me.admin && data && !isNtpActive(data) && (
<Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow>
<Button onClick={openSetTime} variant="outlined" color="primary" startIcon={<AccessTimeIcon />}>
Set Time
</Button>
</ButtonRow>
</Box>
)}
</Box>
{renderSetTimeDialog()}
</>
);
};
return (
<SectionContent title="NTP Status" titleGutter>
{content()}
</SectionContent>
);
};
export default NTPStatusForm;

View File

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

View File

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

View File

@@ -0,0 +1,79 @@
import { FC, useCallback, useState, useEffect } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Box,
LinearProgress,
Typography,
TextField,
Button
} from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { extractErrorMessage } from '../../utils';
import { useSnackbar } from 'notistack';
import { MessageBox } from '../../components';
import * as SecurityApi from '../../api/security';
import { Token } from '../../types';
interface GenerateTokenProps {
username?: string;
onClose: () => void;
}
const GenerateToken: FC<GenerateTokenProps> = ({ username, onClose }) => {
const [token, setToken] = useState<Token>();
const open = !!username;
const { enqueueSnackbar } = useSnackbar();
const getToken = useCallback(async () => {
try {
setToken((await SecurityApi.generateToken(username)).data);
} catch (error: any) {
enqueueSnackbar(extractErrorMessage(error, 'Problem generating token'), { variant: 'error' });
}
}, [username, enqueueSnackbar]);
useEffect(() => {
if (open) {
getToken();
}
}, [open, getToken]);
return (
<Dialog onClose={onClose} aria-labelledby="generate-token-dialog-title" open={!!username} fullWidth maxWidth="sm">
<DialogTitle id="generate-token-dialog-title">Access Token for {username}</DialogTitle>
<DialogContent dividers>
{token ? (
<>
<MessageBox
message="The token below is used with REST API calls that require authorization. It can be passed either as a Bearer token in the
'Authorization' header or in the 'access_token' URL query parameter."
level="info"
my={2}
/>
<Box mt={2} mb={2}>
<TextField label="Token" multiline value={token.token} fullWidth contentEditable={false} />
</Box>
</>
) : (
<Box m={4} textAlign="center">
<LinearProgress />
<Typography variant="h6">Generating token&hellip;</Typography>
</Box>
)}
</DialogContent>
<DialogActions>
<Button startIcon={<CloseIcon />} variant="outlined" onClick={onClose} color="secondary">
Close
</Button>
</DialogActions>
</Dialog>
);
};
export default GenerateToken;

View File

@@ -0,0 +1,176 @@
import { FC, useContext, useState } from 'react';
import { Button, IconButton, Table, TableBody, TableCell, TableFooter, TableHead, TableRow } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import DeleteIcon from '@mui/icons-material/Delete';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import EditIcon from '@mui/icons-material/Edit';
import CheckIcon from '@mui/icons-material/Check';
import CloseIcon from '@mui/icons-material/Close';
import VpnKeyIcon from '@mui/icons-material/VpnKey';
import * as SecurityApi from '../../api/security';
import { SecuritySettings, User } from '../../types';
import { ButtonRow, FormLoader, MessageBox, SectionContent } from '../../components';
import { createUserValidator } from '../../validators';
import { useRest } from '../../utils';
import { AuthenticatedContext } from '../../contexts/authentication';
import GenerateToken from './GenerateToken';
import UserForm from './UserForm';
function compareUsers(a: User, b: User) {
if (a.username < b.username) {
return -1;
}
if (a.username > b.username) {
return 1;
}
return 0;
}
const ManageUsersForm: FC = () => {
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<SecuritySettings>({
read: SecurityApi.readSecuritySettings,
update: SecurityApi.updateSecuritySettings
});
const [user, setUser] = useState<User>();
const [creating, setCreating] = useState<boolean>(false);
const [generatingToken, setGeneratingToken] = useState<string>();
const authenticatedContext = useContext(AuthenticatedContext);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const noAdminConfigured = () => !data.users.find((u) => u.admin);
const removeUser = (toRemove: User) => {
const users = data.users.filter((u) => u.username !== toRemove.username);
setData({ ...data, users });
};
const createUser = () => {
setCreating(true);
setUser({
username: '',
password: '',
admin: true
});
};
const editUser = (toEdit: User) => {
setCreating(false);
setUser({ ...toEdit });
};
const cancelEditingUser = () => {
setUser(undefined);
};
const doneEditingUser = () => {
if (user) {
const users = [...data.users.filter((u) => u.username !== user.username), user];
setData({ ...data, users });
setUser(undefined);
}
};
const closeGenerateToken = () => {
setGeneratingToken(undefined);
};
const generateToken = (username: string) => {
setGeneratingToken(username);
};
const onSubmit = async () => {
await saveData();
authenticatedContext.refresh();
};
return (
<>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Username</TableCell>
<TableCell align="center">is Admin?</TableCell>
<TableCell />
</TableRow>
</TableHead>
<TableBody>
{data.users.sort(compareUsers).map((u) => (
<TableRow key={u.username}>
<TableCell component="th" scope="row">
{u.username}
</TableCell>
<TableCell align="center">{u.admin ? <CheckIcon /> : <CloseIcon />}</TableCell>
<TableCell align="center">
<IconButton
size="small"
disabled={!authenticatedContext.me.admin}
aria-label="Generate Token"
onClick={() => generateToken(u.username)}
>
<VpnKeyIcon />
</IconButton>
<IconButton size="small" aria-label="Delete" onClick={() => removeUser(u)}>
<DeleteIcon />
</IconButton>
<IconButton size="small" aria-label="Edit" onClick={() => editUser(u)}>
<EditIcon />
</IconButton>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2} />
<TableCell align="center">
<Button startIcon={<PersonAddIcon />} variant="outlined" color="secondary" onClick={createUser}>
Add
</Button>
</TableCell>
</TableRow>
</TableFooter>
</Table>
{noAdminConfigured() && (
<MessageBox level="warning" message="You must have at least one admin user configured" my={2} />
)}
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving || noAdminConfigured()}
variant="outlined"
color="primary"
type="submit"
onClick={onSubmit}
>
Save
</Button>
</ButtonRow>
<GenerateToken username={generatingToken} onClose={closeGenerateToken} />
<UserForm
user={user}
setUser={setUser}
creating={creating}
onDoneEditing={doneEditingUser}
onCancelEditing={cancelEditingUser}
validator={createUserValidator(data.users, creating)}
/>
</>
);
};
return (
<SectionContent title="Manage Users" titleGutter>
{content()}
</SectionContent>
);
};
export default ManageUsersForm;

View File

@@ -0,0 +1,31 @@
import { FC } from 'react';
import { Navigate, Routes, Route } from 'react-router-dom';
import { Tab } from '@mui/material';
import { RouterTabs, useRouterTab, useLayoutTitle } from '../../components';
import SecuritySettingsForm from './SecuritySettingsForm';
import ManageUsersForm from './ManageUsersForm';
const Security: FC = () => {
useLayoutTitle('Security');
const { routerTab } = useRouterTab();
return (
<>
<RouterTabs value={routerTab}>
<Tab value="users" label="Manage Users" />
<Tab value="settings" label="Security Settings" />
</RouterTabs>
<Routes>
<Route path="users" element={<ManageUsersForm />} />
<Route path="settings" element={<SecuritySettingsForm />} />
<Route path="/*" element={<Navigate replace to="users" />} />
</Routes>
</>
);
};
export default Security;

View File

@@ -0,0 +1,80 @@
import { FC, useContext, useState } from 'react';
import { ValidateFieldsError } from 'async-validator';
import { Button } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import * as SecurityApi from '../../api/security';
import { SecuritySettings } from '../../types';
import { ButtonRow, FormLoader, MessageBox, SectionContent, ValidatedPasswordField } from '../../components';
import { SECURITY_SETTINGS_VALIDATOR, validate } from '../../validators';
import { updateValue, useRest } from '../../utils';
import { AuthenticatedContext } from '../../contexts/authentication';
const SecuritySettingsForm: FC = () => {
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<SecuritySettings>({
read: SecurityApi.readSecuritySettings,
update: SecurityApi.updateSecuritySettings
});
const authenticatedContext = useContext(AuthenticatedContext);
const updateFormValue = updateValue(setData);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(SECURITY_SETTINGS_VALIDATOR, data);
await saveData();
await authenticatedContext.refresh();
} catch (errors: any) {
setFieldErrors(errors);
}
};
return (
<>
<ValidatedPasswordField
fieldErrors={fieldErrors}
name="jwt_secret"
label="su Password"
fullWidth
variant="outlined"
value={data.jwt_secret}
onChange={updateFormValue}
margin="normal"
/>
<MessageBox
level="info"
message="The su (super user) password is used to sign authentication tokens and also enable admin privileges within the Console."
mt={1}
/>
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
Save
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="Security Settings" titleGutter>
{content()}
</SectionContent>
);
};
export default SecuritySettingsForm;

View File

@@ -0,0 +1,100 @@
import { FC, useState, useEffect } from 'react';
import Schema, { ValidateFieldsError } from 'async-validator';
import CancelIcon from '@mui/icons-material/Cancel';
import PersonAddIcon from '@mui/icons-material/PersonAdd';
import { Button, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material';
import { BlockFormControlLabel, ValidatedPasswordField, ValidatedTextField } from '../../components';
import { User } from '../../types';
import { updateValue } from '../../utils';
import { validate } from '../../validators';
interface UserFormProps {
creating: boolean;
validator: Schema;
user?: User;
setUser: React.Dispatch<React.SetStateAction<User | undefined>>;
onDoneEditing: () => void;
onCancelEditing: () => void;
}
const UserForm: FC<UserFormProps> = ({ creating, validator, user, setUser, onDoneEditing, onCancelEditing }) => {
const updateFormValue = updateValue(setUser);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const open = !!user;
useEffect(() => {
if (open) {
setFieldErrors(undefined);
}
}, [open]);
const validateAndDone = async () => {
if (user) {
try {
setFieldErrors(undefined);
await validate(validator, user);
onDoneEditing();
} catch (errors: any) {
setFieldErrors(errors);
}
}
};
return (
<Dialog onClose={onCancelEditing} aria-labelledby="user-form-dialog-title" open={!!user} fullWidth maxWidth="sm">
{user && (
<>
<DialogTitle id="user-form-dialog-title">{creating ? 'Add' : 'Modify'} User</DialogTitle>
<DialogContent dividers>
<ValidatedTextField
fieldErrors={fieldErrors}
name="username"
label="Username"
fullWidth
variant="outlined"
value={user.username}
disabled={!creating}
onChange={updateFormValue}
margin="normal"
/>
<ValidatedPasswordField
fieldErrors={fieldErrors}
name="password"
label="Password"
fullWidth
variant="outlined"
value={user.password}
onChange={updateFormValue}
margin="normal"
/>
<BlockFormControlLabel
control={<Checkbox name="admin" checked={user.admin} onChange={updateFormValue} />}
label="is Admin?"
/>
</DialogContent>
<DialogActions>
<Button startIcon={<CancelIcon />} variant="outlined" onClick={onCancelEditing} color="secondary">
Cancel
</Button>
<Button
startIcon={<PersonAddIcon />}
variant="outlined"
onClick={validateAndDone}
color="primary"
autoFocus
>
Add
</Button>
</DialogActions>
</>
)}
</Dialog>
);
};
export default UserForm;

View File

@@ -0,0 +1,32 @@
import { AxiosPromise } from 'axios';
import { FC } from 'react';
import { FileUploadConfig } from '../../api/endpoints';
import { MessageBox, SingleUpload, useFileUpload } from '../../components';
interface UploadFirmwareProps {
uploadFirmware: (file: File, config?: FileUploadConfig) => AxiosPromise<void>;
}
const FirmwareFileUpload: FC<UploadFirmwareProps> = ({ uploadFirmware }) => {
const [uploadFile, cancelUpload, uploading, uploadProgress] = useFileUpload({ upload: uploadFirmware });
return (
<>
<MessageBox
message="Upload a new firmware (.bin) file below to replace the existing firmware"
level="warning"
my={2}
/>
<SingleUpload
accept="application/octet-stream"
onDrop={uploadFile}
onCancel={cancelUpload}
uploading={uploading}
progress={uploadProgress}
/>
</>
);
};
export default FirmwareFileUpload;

View File

@@ -0,0 +1,43 @@
import { useEffect } from 'react';
import { FC, useRef, useState } from 'react';
import * as SystemApi from '../../api/system';
import { FormLoader } from '../../components';
const RESTART_TIMEOUT = 2 * 60 * 1000;
const POLL_TIMEOUT = 2000;
const POLL_INTERVAL = 5000;
const FirmwareRestartMonitor: FC = () => {
const [failed, setFailed] = useState<boolean>(false);
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout>();
const timeoutAt = useRef(new Date().getTime() + RESTART_TIMEOUT);
const poll = useRef(async () => {
try {
await SystemApi.readSystemStatus(POLL_TIMEOUT);
document.location.href = '/firmwareUpdated';
} catch (error: any) {
if (new Date().getTime() < timeoutAt.current) {
setTimeoutId(setTimeout(poll.current, POLL_INTERVAL));
} else {
setFailed(true);
}
}
});
useEffect(() => {
poll.current();
}, []);
useEffect(() => () => timeoutId && clearTimeout(timeoutId), [timeoutId]);
return (
<FormLoader
message="EMS-ESP is restarting, please wait&hellip;"
errorMessage={failed ? 'Timed out waiting for device to restart.' : undefined}
/>
);
};
export default FirmwareRestartMonitor;

View File

@@ -0,0 +1,97 @@
import { FC, useState } from 'react';
import { Button, Checkbox } from '@mui/material';
import SaveIcon from '@mui/icons-material/Save';
import * as SystemApi from '../../api/system';
import {
BlockFormControlLabel,
ButtonRow,
FormLoader,
SectionContent,
ValidatedPasswordField,
ValidatedTextField
} from '../../components';
import { OTASettings } from '../../types';
import { numberValue, updateValue, useRest } from '../../utils';
import { ValidateFieldsError } from 'async-validator';
import { validate } from '../../validators';
import { OTA_SETTINGS_VALIDATOR } from '../../validators/system';
const OTASettingsForm: FC = () => {
const { loadData, saving, data, setData, saveData, errorMessage } = useRest<OTASettings>({
read: SystemApi.readOTASettings,
update: SystemApi.updateOTASettings
});
const updateFormValue = updateValue(setData);
const [fieldErrors, setFieldErrors] = useState<ValidateFieldsError>();
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
const validateAndSubmit = async () => {
try {
setFieldErrors(undefined);
await validate(OTA_SETTINGS_VALIDATOR, data);
saveData();
} catch (errors: any) {
setFieldErrors(errors);
}
};
return (
<>
<BlockFormControlLabel
control={<Checkbox name="enabled" checked={data.enabled} onChange={updateFormValue} />}
label="Enable OTA Updates"
/>
<ValidatedTextField
fieldErrors={fieldErrors}
name="port"
label="Port"
fullWidth
variant="outlined"
value={numberValue(data.port)}
type="number"
onChange={updateFormValue}
margin="normal"
/>
<ValidatedPasswordField
fieldErrors={fieldErrors}
name="password"
label="Password"
fullWidth
variant="outlined"
value={data.password}
onChange={updateFormValue}
margin="normal"
/>
<ButtonRow>
<Button
startIcon={<SaveIcon />}
disabled={saving}
variant="outlined"
color="primary"
type="submit"
onClick={validateAndSubmit}
>
Save
</Button>
</ButtonRow>
</>
);
};
return (
<SectionContent title="OTA Settings" titleGutter>
{content()}
</SectionContent>
);
};
export default OTASettingsForm;

View File

@@ -0,0 +1,60 @@
import React, { FC, useContext } from 'react';
import { Navigate, Routes, Route } from 'react-router-dom';
import { Tab } from '@mui/material';
import { useRouterTab, RouterTabs, useLayoutTitle, RequireAdmin } from '../../components';
import { AuthenticatedContext } from '../../contexts/authentication';
import { FeaturesContext } from '../../contexts/features';
import UploadFirmwareForm from './UploadFirmwareForm';
import SystemStatusForm from './SystemStatusForm';
import OTASettingsForm from './OTASettingsForm';
import SystemLog from './SystemLog';
const System: FC = () => {
useLayoutTitle('System');
const { me } = useContext(AuthenticatedContext);
const { features } = useContext(FeaturesContext);
const { routerTab } = useRouterTab();
return (
<>
<RouterTabs value={routerTab}>
<Tab value="status" label="System Status" />
<Tab value="log" label="System Log" />
{features.ota && <Tab value="ota" label="OTA Settings" disabled={!me.admin} />}
{features.upload_firmware && <Tab value="upload" label="Upload Firmware" disabled={!me.admin} />}
</RouterTabs>
<Routes>
<Route path="status" element={<SystemStatusForm />} />
<Route path="log" element={<SystemLog />} />
{features.ota && (
<Route
path="ota"
element={
<RequireAdmin>
<OTASettingsForm />
</RequireAdmin>
}
/>
)}
{features.upload_firmware && (
<Route
path="upload"
element={
<RequireAdmin>
<UploadFirmwareForm />
</RequireAdmin>
}
/>
)}
<Route path="/*" element={<Navigate replace to="status" />} />
</Routes>
</>
);
};
export default System;

View File

@@ -0,0 +1,282 @@
import { FC, useState, useEffect, useCallback, useLayoutEffect } from 'react';
import { Box, styled, Button, Checkbox, MenuItem, Grid, Slider, FormLabel } from '@mui/material';
import * as SystemApi from '../../api/system';
import { addAccessTokenParameter } from '../../api/authentication';
import { SectionContent, FormLoader, BlockFormControlLabel, ValidatedTextField } from '../../components';
import { LogSettings, LogEntry, LogEntries, LogLevel } from '../../types';
import { updateValue, useRest, extractErrorMessage } from '../../utils';
import DownloadIcon from '@mui/icons-material/GetApp';
import { useSnackbar } from 'notistack';
import { EVENT_SOURCE_ROOT } from '../../api/endpoints';
export const LOG_EVENTSOURCE_URL = EVENT_SOURCE_ROOT + 'log';
const useWindowSize = () => {
const [size, setSize] = useState([0, 0]);
useLayoutEffect(() => {
function updateSize() {
setSize([window.innerWidth, window.innerHeight]);
}
window.addEventListener('resize', updateSize);
updateSize();
return () => window.removeEventListener('resize', updateSize);
}, []);
return size;
};
const LogEntryLine = styled('div')(({ theme }) => ({
color: '#bbbbbb',
fontFamily: 'monospace',
fontSize: '14px',
letterSpacing: 'normal',
whiteSpace: 'nowrap'
}));
const topOffset = () => document.getElementById('log-window')?.getBoundingClientRect().bottom || 0;
const leftOffset = () => document.getElementById('log-window')?.getBoundingClientRect().left || 0;
const levelLabel = (level: LogLevel) => {
switch (level) {
case LogLevel.ERROR:
return 'ERROR';
case LogLevel.WARNING:
return 'WARNING';
case LogLevel.NOTICE:
return 'NOTICE';
case LogLevel.INFO:
return 'INFO';
case LogLevel.DEBUG:
return 'DEBUG';
case LogLevel.TRACE:
return 'TRACE';
default:
return '';
}
};
const SystemLog: FC = () => {
useWindowSize();
const { loadData, data, setData } = useRest<LogSettings>({
read: SystemApi.readLogSettings
});
const [errorMessage, setErrorMessage] = useState<string>();
const [reconnectTimeout, setReconnectTimeout] = useState<NodeJS.Timeout>();
const [logEntries, setLogEntries] = useState<LogEntries>({ events: [] });
const [lastIndex, setLastIndex] = useState<number>(0);
const paddedLevelLabel = (level: LogLevel) => {
const label = levelLabel(level);
return data?.compact ? ' ' + label[0] : label.padStart(8, '\xa0');
};
const paddedNameLabel = (name: string) => {
const label = '[' + name + ']';
return data?.compact ? label : label.padEnd(12, '\xa0');
};
const paddedIDLabel = (id: number) => {
const label = id + ':';
return data?.compact ? label : label.padEnd(7, '\xa0');
};
const updateFormValue = updateValue(setData);
const { enqueueSnackbar } = useSnackbar();
const reloadPage = () => {
window.location.reload();
};
const sendSettings = async (new_max_messages: number, new_level: number) => {
if (data) {
try {
const response = await SystemApi.updateLogSettings({
level: new_level,
max_messages: new_max_messages,
compact: data.compact
});
if (response.status !== 200) {
enqueueSnackbar('Problem applying log settings', { variant: 'error' });
}
} catch (error: any) {
enqueueSnackbar(extractErrorMessage(error, 'Problem applying log settings'), { variant: 'error' });
}
}
};
const changeLevel = (event: React.ChangeEvent<HTMLInputElement>) => {
if (data) {
setData({
...data,
level: parseInt(event.target.value)
});
sendSettings(data.max_messages, parseInt(event.target.value));
}
};
const changeMaxMessages = (event: Event, value: number | number[]) => {
if (data) {
setData({
...data,
max_messages: value as number
});
}
};
const onDownload = () => {
let result = '';
for (let i of logEntries.events) {
result += i.t + ' ' + levelLabel(i.l) + ' ' + i.i + ': [' + i.n + '] ' + i.m + '\n';
}
const a = document.createElement('a');
a.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(result));
a.setAttribute('download', 'log.txt');
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
const onMessage = (event: MessageEvent) => {
const rawData = event.data;
if (typeof rawData === 'string' || rawData instanceof String) {
const logentry = JSON.parse(rawData as string) as LogEntry;
if (logentry.i > lastIndex) {
setLastIndex(logentry.i);
setLogEntries((old) => ({ events: [...old.events, logentry] }));
}
}
};
const fetchLog = useCallback(async () => {
try {
setLogEntries((await SystemApi.readLogEntries()).data);
} catch (error: any) {
setErrorMessage(extractErrorMessage(error, 'Failed to fetch log'));
}
}, []);
useEffect(() => {
fetchLog();
}, [fetchLog]);
useEffect(() => {
const es = new EventSource(addAccessTokenParameter(LOG_EVENTSOURCE_URL));
es.onmessage = onMessage;
es.onerror = () => {
if (reconnectTimeout) {
es.close();
setReconnectTimeout(setTimeout(reloadPage, 1000));
}
};
return () => {
es.close();
if (reconnectTimeout) {
clearTimeout(reconnectTimeout);
}
};
// eslint-disable-next-line
}, [reconnectTimeout]);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
return (
<>
<Grid container spacing={3} direction="row" justifyContent="flex-start" alignItems="center">
<Grid item xs={4}>
<ValidatedTextField
name="level"
label="Log Level"
value={data.level}
fullWidth
variant="outlined"
onChange={changeLevel}
margin="normal"
select
>
<MenuItem value={3}>ERROR</MenuItem>
<MenuItem value={4}>WARNING</MenuItem>
<MenuItem value={5}>NOTICE</MenuItem>
<MenuItem value={6}>INFO</MenuItem>
<MenuItem value={7}>DEBUG</MenuItem>
<MenuItem value={9}>ALL</MenuItem>
</ValidatedTextField>
</Grid>
<Grid item xs={3}>
<FormLabel>Buffer size</FormLabel>
<Slider
value={data.max_messages}
valueLabelDisplay="auto"
name="max_messages"
marks={[
{ value: 25, label: '25' },
{ value: 50, label: '50' },
{ value: 75, label: '75' },
{ value: 100, label: '100' }
]}
step={25}
min={25}
max={100}
onChange={changeMaxMessages}
onChangeCommitted={() => sendSettings(data.max_messages, data.level)}
/>
</Grid>
<Grid item>
<BlockFormControlLabel
control={<Checkbox checked={data.compact} onChange={updateFormValue} name="compact" />}
label="Compact"
/>
</Grid>
<Grid item>
<Button startIcon={<DownloadIcon />} variant="outlined" color="secondary" onClick={onDownload}>
Export
</Button>
</Grid>
</Grid>
<Box
sx={{
backgroundColor: 'black',
overflow: 'scroll',
position: 'absolute',
right: 18,
bottom: 18,
left: () => leftOffset(),
top: () => topOffset(),
p: 1
}}
>
{logEntries &&
logEntries.events.map((e) => (
<LogEntryLine key={e.i}>
<span>{e.t}</span>
{data.compact && <span>{paddedLevelLabel(e.l)} </span>}
{!data.compact && <span>{paddedLevelLabel(e.l)}&nbsp;</span>}
<span>{paddedIDLabel(e.i)} </span>
<span>{paddedNameLabel(e.n)} </span>
<span>{e.m}</span>
</LogEntryLine>
))}
</Box>
</>
);
};
return (
<SectionContent title="System Log" titleGutter id="log-window">
{content()}
</SectionContent>
);
};
export default SystemLog;

View File

@@ -0,0 +1,379 @@
import { FC, useContext, useState, useEffect } from 'react';
import { useSnackbar } from 'notistack';
import {
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
Link,
Typography
} from '@mui/material';
import DevicesIcon from '@mui/icons-material/Devices';
import ShowChartIcon from '@mui/icons-material/ShowChart';
import MemoryIcon from '@mui/icons-material/Memory';
import AppsIcon from '@mui/icons-material/Apps';
import SdStorageIcon from '@mui/icons-material/SdStorage';
import FolderIcon from '@mui/icons-material/Folder';
import RefreshIcon from '@mui/icons-material/Refresh';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import BuildIcon from '@mui/icons-material/Build';
import TimerIcon from '@mui/icons-material/Timer';
import CancelIcon from '@mui/icons-material/Cancel';
import { ButtonRow, FormLoader, SectionContent, MessageBox } from '../../components';
import { EspPlatform, SystemStatus, Version } from '../../types';
import * as SystemApi from '../../api/system';
import { extractErrorMessage, useRest } from '../../utils';
import { AuthenticatedContext } from '../../contexts/authentication';
import axios from 'axios';
export const VERSIONCHECK_ENDPOINT = 'https://api.github.com/repos/emsesp/EMS-ESP32/releases/latest';
export const VERSIONCHECK_DEV_ENDPOINT = 'https://api.github.com/repos/emsesp/EMS-ESP32/releases/tags/latest';
export const uploadURL = window.location.origin + '/system/upload';
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
const SystemStatusForm: FC = () => {
const { loadData, data, errorMessage } = useRest<SystemStatus>({ read: SystemApi.readSystemStatus });
const { me } = useContext(AuthenticatedContext);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [confirmFactoryReset, setConfirmFactoryReset] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const { enqueueSnackbar } = useSnackbar();
const [showingVersion, setShowingVersion] = useState<boolean>(false);
const [latestVersion, setLatestVersion] = useState<Version>();
const [latestDevVersion, setLatestDevVersion] = useState<Version>();
useEffect(() => {
axios.get(VERSIONCHECK_ENDPOINT).then((response) => {
setLatestVersion({
version: response.data.name,
url: response.data.assets[1].browser_download_url,
changelog: response.data.html_url
});
});
axios.get(VERSIONCHECK_DEV_ENDPOINT).then((response) => {
setLatestDevVersion({
version: response.data.name.split(/\s+/).splice(-1),
url: response.data.assets[1].browser_download_url,
changelog: response.data.assets[0].browser_download_url
});
});
}, []);
const restart = async () => {
setProcessing(true);
try {
await SystemApi.restart();
enqueueSnackbar('EMS-ESP is restarting...', { variant: 'info' });
} catch (error: any) {
enqueueSnackbar(extractErrorMessage(error, 'Problem restarting device'), { variant: 'error' });
} finally {
setConfirmRestart(false);
setProcessing(false);
}
};
const renderRestartDialog = () => (
<Dialog open={confirmRestart} onClose={() => setConfirmRestart(false)}>
<DialogTitle>Restart</DialogTitle>
<DialogContent dividers>Are you sure you want to restart EMS-ESP?</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmRestart(false)}
color="secondary"
>
Cancel
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={restart}
disabled={processing}
color="primary"
autoFocus
>
Restart
</Button>
</DialogActions>
</Dialog>
);
const renderVersionDialog = () => {
return (
<Dialog open={showingVersion} onClose={() => setShowingVersion(false)}>
<DialogTitle>Version Check</DialogTitle>
<DialogContent dividers>
<MessageBox
my={0}
level="info"
message={'You are currently running EMS-ESP version ' + data?.emsesp_version}
/>
{latestVersion && (
<Box mt={2} mb={2}>
The latest <u>official</u> version is <b>{latestVersion.version}</b>&nbsp;(
<Link target="_blank" href={latestVersion.changelog} color="primary">
{'release notes'}
</Link>
)&nbsp;(
<Link target="_blank" href={latestVersion.url} color="primary">
{'download'}
</Link>
)
</Box>
)}
{latestDevVersion && (
<Box mt={2} mb={2}>
The latest <u>development</u> version is&nbsp;<b>{latestDevVersion.version}</b>
&nbsp;(
<Link target="_blank" href={latestDevVersion.changelog} color="primary">
{'release notes'}
</Link>
)&nbsp;(
<Link target="_blank" href={latestDevVersion.url} color="primary">
{'download'}
</Link>
)
</Box>
)}
<Box color="warning.main" p={0} pl={0} pr={0} mt={4} mb={0}>
<Typography variant="body2">
Use&nbsp;
<Link target="_blank" href={uploadURL} color="primary">
{'UPLOAD FIRMWARE'}
</Link>
&nbsp;to apply the new firmware
</Typography>
</Box>
</DialogContent>
<DialogActions>
<Button variant="outlined" onClick={() => setShowingVersion(false)} color="secondary">
Close
</Button>
</DialogActions>
</Dialog>
);
};
const factoryReset = async () => {
setProcessing(true);
try {
await SystemApi.factoryReset();
enqueueSnackbar('Device has been factory reset and will now restart', { variant: 'info' });
} catch (error: any) {
enqueueSnackbar(extractErrorMessage(error, 'Problem factory resetting the device'), { variant: 'error' });
} finally {
setConfirmFactoryReset(false);
setProcessing(false);
}
};
const renderFactoryResetDialog = () => (
<Dialog open={confirmFactoryReset} onClose={() => setConfirmFactoryReset(false)}>
<DialogTitle>Factory Reset</DialogTitle>
<DialogContent dividers>Are you sure you want to reset the device to its factory defaults?</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmFactoryReset(false)}
color="secondary"
>
Cancel
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={factoryReset}
disabled={processing}
autoFocus
color="error"
>
Factory Reset
</Button>
</DialogActions>
</Dialog>
);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={errorMessage} />;
}
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar>
<BuildIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="EMS-ESP Version" secondary={'v' + data.emsesp_version} />
{latestVersion && (
<Button color="primary" onClick={() => setShowingVersion(true)}>
Version Check
</Button>
)}
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<DevicesIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="Device (Platform / SDK)" secondary={data.esp_platform + ' / ' + data.sdk_version} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<TimerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="System Uptime" secondary={data.uptime} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<ShowChartIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary="CPU Frequency" secondary={data.cpu_freq_mhz + ' MHz'} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<MemoryIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="Heap (Free / Max Alloc)"
secondary={
formatNumber(data.free_heap) +
' / ' +
formatNumber(data.max_alloc_heap) +
' bytes ' +
(data.esp_platform === EspPlatform.ESP8266 ? '(' + data.heap_fragmentation + '% fragmentation)' : '')
}
/>
</ListItem>
{data.esp_platform === EspPlatform.ESP32 && data.psram_size > 0 && (
<>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<AppsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="PSRAM (Size / Free)"
secondary={formatNumber(data.psram_size) + ' / ' + formatNumber(data.free_psram) + ' bytes'}
/>
</ListItem>
</>
)}
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<SdStorageIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="Flash Chip (Size / Speed)"
secondary={
formatNumber(data.flash_chip_size) + ' bytes / ' + (data.flash_chip_speed / 1000000).toFixed(0) + ' MHz'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<FolderIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="File System (Used / Total)"
secondary={
formatNumber(data.fs_used) +
' / ' +
formatNumber(data.fs_total) +
' bytes (' +
formatNumber(data.fs_total - data.fs_used) +
'\xa0bytes free)'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
</List>
<Box display="flex" flexWrap="wrap">
<Box flexGrow={1} sx={{ '& button': { mt: 2 } }}>
<ButtonRow>
<Button startIcon={<RefreshIcon />} variant="outlined" color="secondary" onClick={loadData}>
Refresh
</Button>
</ButtonRow>
</Box>
{me.admin && (
<Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="primary"
onClick={() => setConfirmRestart(true)}
>
Restart
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"
onClick={() => setConfirmFactoryReset(true)}
color="error"
>
Factory reset
</Button>
</ButtonRow>
</Box>
)}
</Box>
{renderVersionDialog()}
{renderRestartDialog()}
{renderFactoryResetDialog()}
</>
);
};
return (
<SectionContent title="System Status" titleGutter>
{content()}
</SectionContent>
);
};
export default SystemStatusForm;

View File

@@ -0,0 +1,26 @@
import { FC, useRef, useState } from 'react';
import * as SystemApi from '../../api/system';
import { SectionContent } from '../../components';
import { FileUploadConfig } from '../../api/endpoints';
import FirmwareFileUpload from './FirmwareFileUpload';
import FirmwareRestartMonitor from './FirmwareRestartMonitor';
const UploadFirmwareForm: FC = () => {
const [restarting, setRestarting] = useState<boolean>();
const uploadFirmware = useRef(async (file: File, config?: FileUploadConfig) => {
const response = await SystemApi.uploadFirmware(file, config);
setRestarting(true);
return response;
});
return (
<SectionContent title="Upload Firmware" titleGutter>
{restarting ? <FirmwareRestartMonitor /> : <FirmwareFileUpload uploadFirmware={uploadFirmware.current} />}
</SectionContent>
);
};
export default UploadFirmwareForm;