mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2025-12-08 08:49:52 +03:00
Merge remote-tracking branch 'origin/v3.4' into dev
This commit is contained in:
32
interface/src/framework/system/FirmwareFileUpload.tsx
Normal file
32
interface/src/framework/system/FirmwareFileUpload.tsx
Normal 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;
|
||||
43
interface/src/framework/system/FirmwareRestartMonitor.tsx
Normal file
43
interface/src/framework/system/FirmwareRestartMonitor.tsx
Normal 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…"
|
||||
errorMessage={failed ? 'Timed out waiting for device to restart.' : undefined}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default FirmwareRestartMonitor;
|
||||
97
interface/src/framework/system/OTASettingsForm.tsx
Normal file
97
interface/src/framework/system/OTASettingsForm.tsx
Normal 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;
|
||||
60
interface/src/framework/system/System.tsx
Normal file
60
interface/src/framework/system/System.tsx
Normal 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;
|
||||
282
interface/src/framework/system/SystemLog.tsx
Normal file
282
interface/src/framework/system/SystemLog.tsx
Normal 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)} </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;
|
||||
379
interface/src/framework/system/SystemStatusForm.tsx
Normal file
379
interface/src/framework/system/SystemStatusForm.tsx
Normal 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> (
|
||||
<Link target="_blank" href={latestVersion.changelog} color="primary">
|
||||
{'release notes'}
|
||||
</Link>
|
||||
) (
|
||||
<Link target="_blank" href={latestVersion.url} color="primary">
|
||||
{'download'}
|
||||
</Link>
|
||||
)
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{latestDevVersion && (
|
||||
<Box mt={2} mb={2}>
|
||||
The latest <u>development</u> version is <b>{latestDevVersion.version}</b>
|
||||
(
|
||||
<Link target="_blank" href={latestDevVersion.changelog} color="primary">
|
||||
{'release notes'}
|
||||
</Link>
|
||||
) (
|
||||
<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
|
||||
<Link target="_blank" href={uploadURL} color="primary">
|
||||
{'UPLOAD FIRMWARE'}
|
||||
</Link>
|
||||
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;
|
||||
26
interface/src/framework/system/UploadFirmwareForm.tsx
Normal file
26
interface/src/framework/system/UploadFirmwareForm.tsx
Normal 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;
|
||||
Reference in New Issue
Block a user