refactor web file structure and seperate settings from status

This commit is contained in:
proddy
2024-07-22 14:46:22 +02:00
parent d0976cd660
commit 53e9a062e8
60 changed files with 149 additions and 251 deletions

View File

@@ -0,0 +1,127 @@
import type { FC } from 'react';
import ComputerIcon from '@mui/icons-material/Computer';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import RefreshIcon from '@mui/icons-material/Refresh';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import {
Avatar,
Button,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
useTheme
} from '@mui/material';
import type { Theme } from '@mui/material';
import * as APApi from 'api/ap';
import { useRequest } from 'alova';
import { ButtonRow, FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { APStatusType } from 'types';
import { APNetworkStatus } from 'types';
export const apStatusHighlight = ({ status }: APStatusType, 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;
}
};
const APStatus: FC = () => {
const { data: data, send: loadData, error } = useRequest(APApi.readAPStatus);
const { LL } = useI18nContext();
useLayoutTitle(LL.STATUS_OF(LL.ACCESS_POINT(0)));
const theme = useTheme();
const apStatus = ({ status }: APStatusType) => {
switch (status) {
case APNetworkStatus.ACTIVE:
return LL.ACTIVE();
case APNetworkStatus.INACTIVE:
return LL.INACTIVE(0);
case APNetworkStatus.LINGERING:
return 'Lingering until idle';
default:
return LL.UNKNOWN();
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: apStatusHighlight(data, theme) }}>
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={apStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>IP</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.ADDRESS_OF('IP')}
secondary={data.ip_address}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.ADDRESS_OF('MAC')}
secondary={data.mac_address}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<ComputerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.AP_CLIENTS()} secondary={data.station_num} />
</ListItem>
<Divider variant="inset" component="li" />
</List>
<ButtonRow>
<Button
startIcon={<RefreshIcon />}
variant="outlined"
color="secondary"
onClick={loadData}
>
{LL.REFRESH()}
</Button>
</ButtonRow>
</>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default APStatus;

View File

@@ -0,0 +1,148 @@
import { useEffect } from 'react';
import type { FC } from 'react';
import RefreshIcon from '@mui/icons-material/Refresh';
import { Button } from '@mui/material';
import {
Body,
Cell,
Header,
HeaderCell,
HeaderRow,
Row,
Table
} from '@table-library/react-table-library/table';
import { useTheme as tableTheme } from '@table-library/react-table-library/theme';
import { useRequest } from 'alova';
import { ButtonRow, FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { Translation } from 'i18n/i18n-types';
import * as EMSESP from '../main/api';
import type { Stat } from '../main/types';
const SystemActivity: FC = () => {
const { data: data, send: loadData, error } = useRequest(EMSESP.readActivity);
const { LL } = useI18nContext();
useLayoutTitle(LL.EMS_BUS_STATUS());
const stats_theme = tableTheme({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`,
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
.th {
height: 36px;
border-bottom: 1px solid #565656;
}
`,
Row: `
.td {
padding: 8px;
border-top: 1px solid #565656;
border-bottom: 1px solid #565656;
}
&:nth-of-type(odd) .td {
background-color: #303030;
}
&:nth-of-type(even) .td {
background-color: #1e1e1e;
}
`,
BaseCell: `
&:not(:first-of-type) {
text-align: center;
}
`
});
useEffect(() => {
const timer = setInterval(() => loadData(), 30000);
return () => {
clearInterval(timer);
};
});
const showName = (id: number) => {
const name: keyof Translation['STATUS_NAMES'] = id;
return LL.STATUS_NAMES[name]();
};
const showQuality = (stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) {
return;
}
if (stat.q === 100) {
return <div style={{ color: '#00FF7F' }}>{stat.q}%</div>;
}
if (stat.q >= 95) {
return <div style={{ color: 'orange' }}>{stat.q}%</div>;
} else {
return <div style={{ color: 'red' }}>{stat.q}%</div>;
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<>
<Table
data={{ nodes: data.stats }}
theme={stats_theme}
layout={{ custom: true }}
>
{(tableList: Stat[]) => (
<>
<Header>
<HeaderRow>
<HeaderCell resize />
<HeaderCell stiff>{LL.SUCCESS()}</HeaderCell>
<HeaderCell stiff>{LL.FAIL()}</HeaderCell>
<HeaderCell stiff>{LL.QUALITY()}</HeaderCell>
</HeaderRow>
</Header>
<Body>
{tableList.map((stat: Stat) => (
<Row key={stat.id} item={stat}>
<Cell>{showName(stat.id)}</Cell>
<Cell stiff>{Intl.NumberFormat().format(stat.s)}</Cell>
<Cell stiff>{Intl.NumberFormat().format(stat.f)}</Cell>
<Cell stiff>{showQuality(stat)}</Cell>
</Row>
))}
</Body>
</>
)}
</Table>
<ButtonRow mt={1}>
<Button
startIcon={<RefreshIcon />}
variant="outlined"
color="secondary"
onClick={loadData}
>
{LL.REFRESH()}
</Button>
</ButtonRow>
</>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default SystemActivity;

View File

@@ -0,0 +1,221 @@
import type { FC } from 'react';
import AppsIcon from '@mui/icons-material/Apps';
import DeveloperBoardIcon from '@mui/icons-material/DeveloperBoard';
import DevicesIcon from '@mui/icons-material/Devices';
import FolderIcon from '@mui/icons-material/Folder';
import MemoryIcon from '@mui/icons-material/Memory';
import RefreshIcon from '@mui/icons-material/Refresh';
import SdCardAlertIcon from '@mui/icons-material/SdCardAlert';
import SdStorageIcon from '@mui/icons-material/SdStorage';
import TapAndPlayIcon from '@mui/icons-material/TapAndPlay';
import {
Avatar,
Box,
Button,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText
} from '@mui/material';
import * as SystemApi from 'api/system';
import { useRequest } from 'alova';
import { ButtonRow, FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import BBQKeesIcon from './bbqkees.svg';
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
const ESPSystemStatus: FC = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.STATUS_OF(LL.HARDWARE()));
const {
data: data,
send: loadData,
error
} = useRequest(SystemApi.readESPSystemStatus, { force: true });
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#003289', color: 'white' }}>
{data.model ? (
<img
src={BBQKeesIcon}
style={{ width: 16, verticalAlign: 'middle' }}
/>
) : (
<TapAndPlayIcon />
)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.HARDWARE() + ' ' + LL.DEVICE()}
secondary={data.model ? data.model : data.cpu_type}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<DevicesIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="SDK"
secondary={data.arduino_version + ' / ESP-IDF ' + data.sdk_version}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<DeveloperBoardIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="CPU"
secondary={
data.esp_platform +
'/' +
data.cpu_type +
' (rev.' +
data.cpu_rev +
', ' +
(data.cpu_cores == 1 ? 'single-core)' : 'dual-core)') +
' @ ' +
data.cpu_freq_mhz +
' Mhz'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<MemoryIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FREE_MEMORY()}
secondary={
formatNumber(data.free_heap) +
' KB (' +
formatNumber(data.max_alloc_heap) +
' KB max alloc)'
}
/>
</ListItem>
{data.psram_size !== undefined && data.free_psram !== undefined && (
<>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<AppsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.PSRAM()}
secondary={
formatNumber(data.psram_size) +
' KB / ' +
formatNumber(data.free_psram) +
' KB'
}
/>
</ListItem>
</>
)}
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<SdStorageIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FLASH()}
secondary={
formatNumber(data.flash_chip_size) +
' KB , ' +
(data.flash_chip_speed / 1000000).toFixed(0) +
' MHz'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<SdCardAlertIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.APPSIZE()}
secondary={
data.partition +
': ' +
formatNumber(data.app_used) +
' KB / ' +
formatNumber(data.app_free) +
' KB'
}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<FolderIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.FILESYSTEM()}
secondary={
formatNumber(data.fs_used) +
' KB / ' +
formatNumber(data.fs_free) +
' KB'
}
/>
</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}
>
{LL.REFRESH()}
</Button>
</ButtonRow>
</Box>
</Box>
</>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default ESPSystemStatus;

View File

@@ -0,0 +1,181 @@
import type { FC } from 'react';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
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 {
Avatar,
Button,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
useTheme
} from '@mui/material';
import type { Theme } from '@mui/material';
import * as MqttApi from 'api/mqtt';
import { useRequest } from 'alova';
import { ButtonRow, FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { MqttStatusType } from 'types';
import { MqttDisconnectReason } from 'types';
export const mqttStatusHighlight = (
{ enabled, connected }: MqttStatusType,
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 }: MqttStatusType,
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 mqttQueueHighlight = (
{ mqtt_queued }: MqttStatusType,
theme: Theme
) => {
if (mqtt_queued <= 1) return theme.palette.success.main;
return theme.palette.warning.main;
};
const MqttStatus: FC = () => {
const { data: data, send: loadData, error } = useRequest(MqttApi.readMqttStatus);
const { LL } = useI18nContext();
useLayoutTitle(LL.STATUS_OF('MQTT'));
const theme = useTheme();
const mqttStatus = ({ enabled, connected, connect_count }: MqttStatusType) => {
if (!enabled) {
return LL.NOT_ENABLED();
}
if (connected) {
return LL.CONNECTED(0) + (connect_count > 1 ? ' (' + connect_count + ')' : '');
}
return LL.DISCONNECTED() + (connect_count > 1 ? ' (' + connect_count + ')' : '');
};
const disconnectReason = ({ disconnect_reason }: MqttStatusType) => {
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.TLS_BAD_FINGERPRINT:
return 'TLS fingerprint invalid';
default:
return 'Unknown';
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
const renderConnectionStatus = () => (
<>
{!data.connected && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<ReportIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.DISCONNECT_REASON()}
secondary={disconnectReason(data)}
/>
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ID_OF(LL.CLIENT())} secondary={data.client_id} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttQueueHighlight(data, theme) }}>
<AutoAwesomeMotionIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.MQTT_QUEUE()} secondary={data.mqtt_queued} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttPublishHighlight(data, theme) }}>
<SpeakerNotesOffIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ERRORS_OF('MQTT')} secondary={data.mqtt_fails} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: mqttStatusHighlight(data, theme) }}>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{data.enabled && renderConnectionStatus()}
</List>
<ButtonRow>
<Button
startIcon={<RefreshIcon />}
variant="outlined"
color="secondary"
onClick={loadData}
>
{LL.REFRESH()}
</Button>
</ButtonRow>
</>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default MqttStatus;

View File

@@ -0,0 +1,253 @@
import { useState } from 'react';
import type { FC } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import CancelIcon from '@mui/icons-material/Cancel';
import DnsIcon from '@mui/icons-material/Dns';
import RefreshIcon from '@mui/icons-material/Refresh';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
import UpdateIcon from '@mui/icons-material/Update';
import {
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
TextField,
Typography,
useTheme
} from '@mui/material';
import type { Theme } from '@mui/material';
import * as NTPApi from 'api/ntp';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova';
import { ButtonRow, FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { NTPStatusType, Time } from 'types';
import { NTPSyncStatus } from 'types';
import { formatDateTime, formatLocalDateTime } from 'utils';
const NTPStatus: FC = () => {
const { data: data, send: loadData, error } = useRequest(NTPApi.readNTPStatus);
const [localTime, setLocalTime] = useState<string>('');
const [settingTime, setSettingTime] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const { LL } = useI18nContext();
useLayoutTitle(LL.STATUS_OF('NTP'));
const { send: updateTime } = useRequest(
(local_time: Time) => NTPApi.updateTime(local_time),
{
immediate: false
}
);
NTPApi.updateTime;
const isNtpActive = ({ status }: NTPStatusType) =>
status === NTPSyncStatus.NTP_ACTIVE;
const isNtpEnabled = ({ status }: NTPStatusType) =>
status !== NTPSyncStatus.NTP_DISABLED;
const ntpStatusHighlight = ({ status }: NTPStatusType, theme: Theme) => {
switch (status) {
case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main;
case NTPSyncStatus.NTP_INACTIVE:
return theme.palette.error.main;
case NTPSyncStatus.NTP_ACTIVE:
return theme.palette.success.main;
default:
return theme.palette.error.main;
}
};
const updateLocalTime = (event: React.ChangeEvent<HTMLInputElement>) =>
setLocalTime(event.target.value);
const openSetTime = () => {
setLocalTime(formatLocalDateTime(new Date()));
setSettingTime(true);
};
const theme = useTheme();
const ntpStatus = ({ status }: NTPStatusType) => {
switch (status) {
case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED();
case NTPSyncStatus.NTP_INACTIVE:
return LL.INACTIVE(0);
case NTPSyncStatus.NTP_ACTIVE:
return LL.ACTIVE();
default:
return LL.UNKNOWN();
}
};
const configureTime = async () => {
setProcessing(true);
await updateTime({ local_time: formatLocalDateTime(new Date(localTime)) })
.then(async () => {
toast.success(LL.TIME_SET());
setSettingTime(false);
await loadData();
})
.catch(() => {
toast.error(LL.PROBLEM_UPDATING());
})
.finally(() => {
setProcessing(false);
});
};
const renderSetTimeDialog = () => (
<Dialog
sx={dialogStyle}
open={settingTime}
onClose={() => setSettingTime(false)}
>
<DialogTitle>{LL.SET_TIME(1)}</DialogTitle>
<DialogContent dividers>
<Box color="warning.main" p={0} pl={0} pr={0} mt={0} mb={2}>
<Typography variant="body2">{LL.SET_TIME_TEXT()}</Typography>
</Box>
<TextField
label={LL.LOCAL_TIME(0)}
type="datetime-local"
value={localTime}
onChange={updateLocalTime}
disabled={processing}
fullWidth
InputLabelProps={{
shrink: true
}}
/>
</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setSettingTime(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<AccessTimeIcon />}
variant="outlined"
onClick={configureTime}
disabled={processing}
color="primary"
>
{LL.UPDATE()}
</Button>
</DialogActions>
</Dialog>
);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<>
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: ntpStatusHighlight(data, theme) }}>
<UpdateIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={ntpStatus(data)} />
</ListItem>
<Divider variant="inset" component="li" />
{isNtpEnabled(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<DnsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.NTP_SERVER()} secondary={data.server} />
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
<ListItem>
<ListItemAvatar>
<Avatar>
<AccessTimeIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.LOCAL_TIME(0)}
secondary={formatDateTime(data.local_time)}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<SwapVerticalCircleIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.UTC_TIME()}
secondary={formatDateTime(data.utc_time)}
/>
</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}
>
{LL.REFRESH()}
</Button>
</ButtonRow>
</Box>
{data && !isNtpActive(data) && (
<Box flexWrap="nowrap" whiteSpace="nowrap">
<ButtonRow>
<Button
onClick={openSetTime}
variant="outlined"
color="primary"
startIcon={<AccessTimeIcon />}
>
{LL.SET_TIME(0)}
</Button>
</ButtonRow>
</Box>
)}
</Box>
{renderSetTimeDialog()}
</>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default NTPStatus;

View File

@@ -0,0 +1,239 @@
import type { FC } from 'react';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DnsIcon from '@mui/icons-material/Dns';
import GiteIcon from '@mui/icons-material/Gite';
import RefreshIcon from '@mui/icons-material/Refresh';
import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import SettingsInputComponentIcon from '@mui/icons-material/SettingsInputComponent';
import WifiIcon from '@mui/icons-material/Wifi';
import {
Avatar,
Button,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
useTheme
} from '@mui/material';
import type { Theme } from '@mui/material';
import * as NetworkApi from 'api/network';
import { useRequest } from 'alova';
import { ButtonRow, FormLoader, SectionContent, useLayoutTitle } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { NetworkStatusType } from 'types';
import { NetworkConnectionStatus } from 'types';
const isConnected = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const networkStatusHighlight = ({ status }: NetworkStatusType, 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 networkQualityHighlight = ({ rssi }: NetworkStatusType, theme: Theme) => {
if (rssi <= -85) {
return theme.palette.error.main;
} else if (rssi <= -75) {
return theme.palette.warning.main;
}
return theme.palette.success.main;
};
export const isWiFi = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const dnsServers = ({ dns_ip_1, dns_ip_2 }: NetworkStatusType) => {
if (!dns_ip_1) {
return 'none';
}
return dns_ip_1 + (!dns_ip_2 || dns_ip_2 === '0.0.0.0' ? '' : ',' + dns_ip_2);
};
const IPs = (status: NetworkStatusType) => {
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 NetworkStatus: FC = () => {
const {
data: data,
send: loadData,
error
} = useRequest(NetworkApi.readNetworkStatus);
const { LL } = useI18nContext();
useLayoutTitle(LL.STATUS_OF(LL.NETWORK(1)));
const theme = useTheme();
const networkStatus = ({ status }: NetworkStatusType) => {
switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return LL.IDLE();
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi)';
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED(0);
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST();
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
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" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
<GiteIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.HOSTNAME()} secondary={data.hostname} />
</ListItem>
<Divider variant="inset" component="li" />
{isWiFi(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkQualityHighlight(data, theme) }}>
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="SSID (RSSI)"
secondary={data.ssid + ' (' + data.rssi + ' dBm)'}
/>
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
{isConnected(data) && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>IP</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={IPs(data)} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.ADDRESS_OF('MAC')}
secondary={data.mac_address}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>#</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.NETWORK_SUBNET()}
secondary={data.subnet_mask}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<SettingsInputComponentIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.NETWORK_GATEWAY()}
secondary={data.gateway_ip || 'none'}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<DnsIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.NETWORK_DNS()}
secondary={dnsServers(data)}
/>
</ListItem>
<Divider variant="inset" component="li" />
</>
)}
</List>
<ButtonRow>
<Button
startIcon={<RefreshIcon />}
variant="outlined"
color="secondary"
onClick={loadData}
>
{LL.REFRESH()}
</Button>
</ButtonRow>
</>
);
};
return <SectionContent>{content()}</SectionContent>;
};
export default NetworkStatus;

View File

@@ -0,0 +1,47 @@
import { useEffect, useRef, useState } from 'react';
import type { FC } from 'react';
import * as SystemApi from 'api/system';
import { useRequest } from 'alova';
import { FormLoader } from 'components';
import { useI18nContext } from 'i18n/i18n-react';
const RESTART_TIMEOUT = 2 * 60 * 1000;
const POLL_INTERVAL = 3000;
const RestartMonitor: FC = () => {
const [failed, setFailed] = useState<boolean>(false);
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout>();
const { LL } = useI18nContext();
const { send } = useRequest(SystemApi.readSystemStatus, { force: true });
const timeoutAt = useRef(new Date().getTime() + RESTART_TIMEOUT);
const poll = useRef(async () => {
try {
await send();
document.location.href = '/';
} catch {
if (new Date().getTime() < timeoutAt.current) {
setTimeoutId(setTimeout(poll.current, POLL_INTERVAL));
} else {
setFailed(true);
}
}
});
useEffect(() => {
void poll.current();
}, []);
useEffect(() => () => timeoutId && clearTimeout(timeoutId), [timeoutId]);
return (
<FormLoader
message={LL.APPLICATION_RESTARTING() + '...'}
errorMessage={failed ? 'Timed out' : undefined}
/>
);
};
export default RestartMonitor;

View File

@@ -0,0 +1,493 @@
import { type FC, useContext, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import BuildIcon from '@mui/icons-material/Build';
import CancelIcon from '@mui/icons-material/Cancel';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
import LogoDevIcon from '@mui/icons-material/LogoDev';
import MemoryIcon from '@mui/icons-material/Memory';
import PermScanWifiIcon from '@mui/icons-material/PermScanWifi';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import RefreshIcon from '@mui/icons-material/Refresh';
import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import TimerIcon from '@mui/icons-material/Timer';
import UpgradeIcon from '@mui/icons-material/Upgrade';
import WifiIcon from '@mui/icons-material/Wifi';
import {
Avatar,
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
Divider,
List,
ListItem,
ListItemAvatar,
ListItemText,
useTheme
} from '@mui/material';
import * as SystemApi from 'api/system';
import * as EMSESP from 'app/main/api';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova';
import { busConnectionStatus } from 'app/main/types';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import { NTPSyncStatus, NetworkConnectionStatus } from 'types';
import RestartMonitor from './RestartMonitor';
const SystemStatus: FC = () => {
const { LL } = useI18nContext();
const navigate = useNavigate();
useLayoutTitle(LL.STATUS_OF(''));
const { me } = useContext(AuthenticatedContext);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [confirmScan, setConfirmScan] = useState<boolean>(false);
const [processing, setProcessing] = useState<boolean>(false);
const [restarting, setRestarting] = useState<boolean>();
const { send: restartCommand } = useRequest(SystemApi.restart(), {
immediate: false
});
const { send: partitionCommand } = useRequest(SystemApi.partition(), {
immediate: false
});
const {
data: data,
send: loadData,
error
} = useRequest(SystemApi.readSystemStatus, { force: true });
const { send: scanDevices } = useRequest(EMSESP.scanDevices, {
immediate: false
});
const theme = useTheme();
const formatDurationSec = (duration_sec: number) => {
const days = Math.trunc((duration_sec * 1000) / 86400000);
const hours = Math.trunc((duration_sec * 1000) / 3600000) % 24;
const minutes = Math.trunc((duration_sec * 1000) / 60000) % 60;
const seconds = Math.trunc((duration_sec * 1000) / 1000) % 60;
let formatted = '';
if (days) {
formatted += LL.NUM_DAYS({ num: days }) + ' ';
}
if (hours) {
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
}
if (minutes) {
formatted += LL.NUM_MINUTES({ num: minutes }) + ' ';
}
formatted += LL.NUM_SECONDS({ num: seconds });
return formatted;
};
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
const busStatus = () => {
if (data) {
switch (data.status) {
case busConnectionStatus.BUS_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (' + formatDurationSec(data.bus_uptime) + ')';
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return LL.TX_ISSUES();
case busConnectionStatus.BUS_STATUS_OFFLINE:
return LL.DISCONNECTED();
}
}
return 'Unknown';
};
const busStatusHighlight = () => {
switch (data.status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main;
case busConnectionStatus.BUS_STATUS_CONNECTED:
return theme.palette.success.main;
case busConnectionStatus.BUS_STATUS_OFFLINE:
return theme.palette.error.main;
default:
return theme.palette.warning.main;
}
};
const ntpStatus = () => {
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return LL.NOT_ENABLED();
case NTPSyncStatus.NTP_INACTIVE:
return LL.INACTIVE(0);
case NTPSyncStatus.NTP_ACTIVE:
return LL.ACTIVE();
default:
return LL.UNKNOWN();
}
};
const ntpStatusHighlight = () => {
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main;
case NTPSyncStatus.NTP_INACTIVE:
return theme.palette.error.main;
case NTPSyncStatus.NTP_ACTIVE:
return theme.palette.success.main;
default:
return theme.palette.error.main;
}
};
const networkStatusHighlight = () => {
switch (data.network_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 = () => {
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
return LL.IDLE();
case NetworkConnectionStatus.WIFI_STATUS_NO_SSID_AVAIL:
return 'No SSID Available';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (WiFi, ' + data.wifi_rssi + ' dBm)';
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED(0);
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST();
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const activeHighlight = (value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main;
const scan = async () => {
await scanDevices()
.then(() => {
toast.info(LL.SCANNING() + '...');
})
.catch((error: Error) => {
toast.error(error.message);
});
setConfirmScan(false);
};
const renderScanDialog = () => (
<Dialog
sx={dialogStyle}
open={confirmScan}
onClose={() => setConfirmScan(false)}
>
<DialogTitle>{LL.SCAN_DEVICES()}</DialogTitle>
<DialogContent dividers>{LL.EMS_SCAN()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmScan(false)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PermScanWifiIcon />}
variant="outlined"
onClick={scan}
color="primary"
>
{LL.SCAN()}
</Button>
</DialogActions>
</Dialog>
);
const restart = async () => {
setProcessing(true);
await restartCommand()
.then(() => {
setRestarting(true);
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(() => {
setConfirmRestart(false);
setProcessing(false);
});
};
const partition = async () => {
setProcessing(true);
await partitionCommand()
.then(() => {
setRestarting(true);
})
.catch((error: Error) => {
toast.error(error.message);
})
.finally(() => {
setConfirmRestart(false);
setProcessing(false);
});
};
const renderRestartDialog = () => (
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={() => setConfirmRestart(false)}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={() => setConfirmRestart(false)}
disabled={processing}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={partition}
disabled={processing}
color="warning"
>
EMS-ESP Loader
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={restart}
disabled={processing}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
);
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message} />;
}
return (
<>
<List sx={{ borderRadius: 3, border: '2px solid grey' }}>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
<TimerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.UPTIME()}
secondary={formatDurationSec(data.uptime)}
/>
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmRestart(true)}
>
{LL.RESTART()}
</Button>
)}
</ListItem>
<Divider variant="inset" component="li" />
<ListMenuItem
disabled={!me.admin}
icon={LogoDevIcon}
bgcolor="#40828f"
label={LL.LOG_OF(LL.SYSTEM(0))}
text={data.emsesp_version}
to="/status/log"
/>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#5d89f7', color: 'white' }}>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.ACTIVE_DEVICES()}
secondary={
LL.NUM_DEVICES({ num: data.num_devices }) +
', ' +
LL.NUM_TEMP_SENSORS({ num: data.num_sensors }) +
', ' +
LL.NUM_ANALOG_SENSORS({ num: data.num_analogs })
}
/>
{me.admin && (
<Button
startIcon={<PermScanWifiIcon />}
variant="outlined"
color="primary"
onClick={() => setConfirmScan(true)}
>
{LL.SCAN_DEVICES()}
</Button>
)}
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#134ba2', color: 'white' }}>
<BuildIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.EMS_ESP_VER()}
secondary={data.emsesp_version}
/>
{me.admin && (
<Button
startIcon={<UpgradeIcon />}
variant="outlined"
color="primary"
onClick={() => navigate('/settings/upload')}
>
{LL.UPDATE()}
</Button>
)}
</ListItem>
<Divider variant="inset" component="li" />
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight()}
label={LL.EMS_BUS_STATUS()}
text={busStatus()}
to="/status/activity"
/>
<Divider variant="inset" component="li" />
<ListMenuItem
disabled={!me.admin}
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.STATUS_OF(LL.HARDWARE())}
text={formatNumber(data.free_heap) + ' KB' + ' ' + LL.FREE_MEMORY()}
to="/status/hardwarestatus"
/>
<Divider variant="inset" component="li" />
<ListMenuItem
disabled={!me.admin}
icon={
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon
}
bgcolor={networkStatusHighlight()}
label={LL.STATUS_OF(LL.NETWORK(1))}
text={networkStatus()}
to="/status/network"
/>
<Divider variant="inset" component="li" />
<ListMenuItem
disabled={!me.admin}
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label={LL.STATUS_OF('MQTT')}
text={data.mqtt_status ? LL.ACTIVE() : LL.INACTIVE(0)}
to="/status/mqtt"
/>
<Divider variant="inset" component="li" />
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight()}
label={LL.STATUS_OF('NTP')}
text={ntpStatus()}
to="/status/ntp"
/>
<Divider variant="inset" component="li" />
<ListMenuItem
disabled={!me.admin}
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.STATUS_OF(LL.ACCESS_POINT(0))}
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
to="/status/ap"
/>
</List>
{renderScanDialog()}
{renderRestartDialog()}
<Box mt={2} display="flex" flexWrap="wrap">
<Button
startIcon={<RefreshIcon />}
variant="outlined"
color="secondary"
onClick={loadData}
>
{LL.REFRESH()}
</Button>
</Box>
</>
);
};
return (
<SectionContent>{restarting ? <RestartMonitor /> : content()}</SectionContent>
);
};
export default SystemStatus;

View File

@@ -0,0 +1,299 @@
import { useEffect, useRef, useState } from 'react';
import type { FC } from 'react';
import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp';
import WarningIcon from '@mui/icons-material/Warning';
import {
Box,
Button,
Checkbox,
Grid,
MenuItem,
TextField,
styled
} from '@mui/material';
import * as SystemApi from 'api/system';
import { fetchLogES } from 'api/system';
import { useSSE } from '@alova/scene-react';
import { useRequest } from 'alova';
import {
BlockFormControlLabel,
BlockNavigation,
FormLoader,
SectionContent,
useLayoutTitle
} from 'components';
import { useI18nContext } from 'i18n/i18n-react';
import type { LogEntry, LogSettings } from 'types';
import { LogLevel } from 'types';
import { updateValueDirty, useRest } from 'utils';
const ButtonTextColors = {
[LogLevel.ERROR]: '#ff0000', // red
[LogLevel.WARNING]: '#ffcc00', // yellow
[LogLevel.NOTICE]: '#ffffff', // white
[LogLevel.INFO]: '#ffffff', // yellow
[LogLevel.DEBUG]: '#00ffff', // cyan
[LogLevel.TRACE]: '#00ffff' // cyan
};
const LogEntryLine = styled('div')(
({ details: { level } }: { details: { level: LogLevel } }) => ({
color: ButtonTextColors[level],
font: '14px monospace',
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 = () => {
const { LL } = useI18nContext();
useLayoutTitle(LL.LOG_OF(LL.SYSTEM(0)));
const {
loadData,
data,
updateDataValue,
origData,
dirtyFlags,
setDirtyFlags,
blocker,
saveData,
errorMessage
} = useRest<LogSettings>({
read: SystemApi.readLogSettings,
update: SystemApi.updateLogSettings
});
const [logEntries, setLogEntries] = useState<LogEntry[]>([]);
const [lastIndex, setLastIndex] = useState<number>(0);
const updateFormValue = updateValueDirty(
origData,
dirtyFlags,
setDirtyFlags,
updateDataValue
);
// eslint-disable-next-line @typescript-eslint/unbound-method
const { onMessage, onError } = useSSE(fetchLogES, {
immediate: true,
// withCredentials: true,
interceptByGlobalResponded: false
});
onMessage((message: { id: number; data: string }) => {
const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry;
if (logentry.i > lastIndex) {
setLastIndex(logentry.i);
setLogEntries((log) => [...log, logentry]);
}
});
onError(() => {
toast.error('No connection to Log server');
});
// called on page load to reset pointer and fetch all log entries
useRequest(SystemApi.fetchLog());
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 onDownload = () => {
let result = '';
for (const i of logEntries) {
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 saveSettings = async () => {
await saveData();
};
// handle scrolling
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (logEntries.length) {
ref.current?.scrollIntoView({
behavior: 'smooth',
block: 'end'
});
}
}, [logEntries.length]);
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}>
<TextField
name="level"
label={LL.LOG_LEVEL()}
value={data.level}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={-1}>OFF</MenuItem>
<MenuItem value={3}>ERROR</MenuItem>
<MenuItem value={4}>WARNING</MenuItem>
<MenuItem value={5}>NOTICE</MenuItem>
<MenuItem value={6}>INFO</MenuItem>
<MenuItem value={9}>ALL</MenuItem>
</TextField>
</Grid>
<Grid item xs={4}>
<TextField
name="max_messages"
label={LL.BUFFER_SIZE()}
value={data.max_messages}
fullWidth
variant="outlined"
onChange={updateFormValue}
margin="normal"
select
>
<MenuItem value={25}>25</MenuItem>
<MenuItem value={50}>50</MenuItem>
<MenuItem value={75}>75</MenuItem>
<MenuItem value={100}>100</MenuItem>
</TextField>
</Grid>
<Grid item xs={2}>
<BlockFormControlLabel
control={
<Checkbox
checked={data.compact}
onChange={updateFormValue}
name="compact"
/>
}
label={LL.COMPACT()}
/>
</Grid>
<Box
sx={{
'& button, & a, & .MuiCard-root': {
ml: 3
}
}}
>
<Button
startIcon={<DownloadIcon />}
variant="outlined"
color="secondary"
onClick={onDownload}
>
{LL.EXPORT()}
</Button>
{dirtyFlags && dirtyFlags.length !== 0 && (
<Button
startIcon={<WarningIcon color="warning" />}
variant="contained"
color="info"
onClick={saveSettings}
>
{LL.APPLY_CHANGES(dirtyFlags.length)}
</Button>
)}
</Box>
</Grid>
<Box
sx={{
backgroundColor: 'black',
overflowY: 'scroll',
position: 'absolute',
right: 18,
bottom: 18,
left: () => leftOffset(),
top: () => topOffset(),
p: 1
}}
>
{logEntries.map((e) => (
<LogEntryLine details={{ level: e.l }} key={e.i}>
<span>{e.t}</span>
<span>{paddedLevelLabel(e.l)}&nbsp;</span>
<span>{paddedIDLabel(e.i)} </span>
<span>{paddedNameLabel(e.n)} </span>
<span>{e.m}</span>
</LogEntryLine>
))}
<div ref={ref} />
</Box>
</>
);
};
return (
<SectionContent id="log-window">
{blocker ? <BlockNavigation blocker={blocker} /> : null}
{content()}
</SectionContent>
);
};
export default SystemLog;

View File

@@ -0,0 +1,26 @@
<svg width="20mm" height="30.146mm" version="1.1" viewBox="0 0 20 30.146" xmlns="http://www.w3.org/2000/svg">
<g transform="translate(-86.195 -104.7)">
<g transform="matrix(.53414 0 0 .53414 70.913 49.568)">
<g fill="#fff">
<path d="m66.034 122.7h-37.398a18.722 18.722 0 0 0-0.02643 0.52631 18.722 18.722 0 0 0 18.722 18.722 18.722 18.722 0 0 0 18.722-18.722 18.722 18.722 0 0 0-0.01882-0.52631z" color="#000000" stroke-width=".15037" style="-inkscape-stroke:none"/>
<path d="m30.943 141.6v2h32.174v-2z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m40.738 142-6.9004 11.951 1.7305 1 6.9023-11.951z" color="#000000" style="-inkscape-stroke:none"/>
<g fill="#fff" fill-rule="evenodd">
<path d="m32.395 158.45-0.0041-7.9929 6.9282 4z" color="#000000" stroke-width=".8pt" style="-inkscape-stroke:none"/>
<path d="m31.857 149.54 0.0039 9.8398 0.80078-0.46094 7.7246-4.4551zm1.0664 1.8457 5.3281 3.0762-5.3242 3.0723z" color="#000000" style="-inkscape-stroke:none"/>
</g>
<path d="m56.547 103.22-1.5645 1.2461s0.33514 0.43498 0.48828 1.0391c0.15315 0.60408 0.18926 1.209-0.57617 1.9688-1.1965 1.1876-1.5061 2.5227-1.4004 3.4902 0.10574 0.96749 0.5918 1.6426 0.5918 1.6426l1.6211-1.1699s-0.17552-0.2403-0.22461-0.68946c-0.04909-0.44915-0.0062-1.0331 0.82031-1.8535 1.2576-1.2483 1.3774-2.8142 1.1074-3.8789s-0.86328-1.7949-0.86328-1.7949z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m48.918 103.22-1.5645 1.2461s0.33513 0.43498 0.48828 1.0391 0.18926 1.209-0.57617 1.9688c-1.1965 1.1876-1.5042 2.5227-1.3984 3.4902 0.10574 0.96749 0.58984 1.6426 0.58984 1.6426l1.623-1.1699s-0.17552-0.2403-0.22461-0.68946c-0.04909-0.44915-0.0062-1.0331 0.82031-1.8535 1.2576-1.2483 1.3754-2.8142 1.1055-3.8789s-0.86328-1.7949-0.86328-1.7949z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m41.291 103.22-1.5664 1.2461s0.33709 0.43498 0.49024 1.0391 0.18926 1.209-0.57617 1.9688c-1.1965 1.1876-1.5061 2.5227-1.4004 3.4902s0.5918 1.6426 0.5918 1.6426l1.6211-1.1699s-0.17552-0.2403-0.22461-0.68946c-0.04909-0.44916-0.0062-1.0331 0.82031-1.8535 1.2576-1.2483 1.3754-2.8142 1.1055-3.8789-0.26993-1.0647-0.86133-1.7949-0.86133-1.7949z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m54.896 113.47c-1.4548 0-2.706 0.87699-3.5293 2.0957-0.82334 1.2187-1.291 2.8147-1.291 4.5586v8e-3c5.87e-4 0.0787 0.0036 0.15349 0.0059 0.22656l1.998-0.0605c-0.0019-0.0604-0.0034-0.11856-0.0039-0.17383v-2e-3c-1.6e-5 -2e-3 1.4e-5 -4e-3 0-6e-3 0.0015-1.3755 0.38921-2.6056 0.94726-3.4316 0.55917-0.82768 1.2182-1.2148 1.873-1.2148 0.65485 0 1.3139 0.38716 1.873 1.2148s0.94727 2.0607 0.94727 3.4394h2c0-1.7439-0.46768-3.3399-1.291-4.5586-0.82334-1.2187-2.0745-2.0957-3.5293-2.0957z" color="#000000" style="-inkscape-stroke:none;paint-order:stroke fill markers"/>
<path d="m47.27 113.47c-1.4548 0-2.706 0.87699-3.5293 2.0957-0.82334 1.2187-1.291 2.8147-1.291 4.5586h2c0-1.3788 0.38809-2.6118 0.94726-3.4394 0.55917-0.82768 1.2182-1.2148 1.873-1.2148 0.65484-1e-5 1.3119 0.38716 1.8711 1.2148 0.55802 0.82598 0.94567 2.0563 0.94727 3.4316-0.0018 0.0584-0.0035 0.11387-0.0059 0.16406l1.998 0.0957c0.0037-0.0788 0.0057-0.15249 0.0078-0.22071l2e-3 -0.0156v-0.0156c0-1.7439-0.46767-3.3399-1.291-4.5586-0.82334-1.2187-2.0745-2.0957-3.5293-2.0957z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m39.641 113.47c-1.4548 0-2.706 0.87699-3.5293 2.0957-0.82334 1.2187-1.291 2.8147-1.291 4.5586v0.012c8.96e-4 0.0721 0.0037 0.15312 0.0078 0.24024l1.9961-0.0957c-0.0021-0.0456-0.0031-0.0976-0.0039-0.15625v-2e-3c3.87e-4 -1.378 0.38837-2.6102 0.94727-3.4375 0.55917-0.82768 1.2182-1.2148 1.873-1.2148 0.65484 0 1.3139 0.38716 1.873 1.2148s0.94726 2.0607 0.94726 3.4394h2c0-1.7439-0.46768-3.3399-1.291-4.5586s-2.0745-2.0957-3.5293-2.0957z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m57.557 119.33v2h5.8418v-2z" color="#000000" style="-inkscape-stroke:none"/>
<path d="m31.137 119.33v2h5.8418v-2z" color="#000000" style="-inkscape-stroke:none"/>
</g>
<path d="m35.826 119.95v0.77149h22.883v-0.77149z" color="#000000" fill-opacity="0" style="-inkscape-stroke:none"/>
<path d="m59.064 150.73c-2.4534 0-4.4629 2.0115-4.4629 4.4648s2.0095 4.4629 4.4629 4.4629 4.4629-2.0095 4.4629-4.4629-2.0095-4.4648-4.4629-4.4648zm0 2c1.3725 0 2.4629 1.0924 2.4629 2.4648s-1.0904 2.4629-2.4629 2.4629-2.4629-1.0904-2.4629-2.4629 1.0904-2.4648 2.4629-2.4648z" color="#000000" fill="#fff" style="-inkscape-stroke:none;paint-order:stroke fill markers"/>
<path d="m54.932 142.14-1.8359 0.79687 3.752 8.6582 1.8359-0.79493z" color="#000000" fill="#fff" style="-inkscape-stroke:none"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.6 KiB