optimizations

This commit is contained in:
proddy
2025-10-28 22:19:08 +01:00
parent 55b893362c
commit 3abfb7bb9c
93 changed files with 3953 additions and 3361 deletions

View File

@@ -34,37 +34,43 @@ export const apStatusHighlight = ({ status }: APStatusType, theme: Theme) => {
}
};
const getApStatusText = (
status: APNetworkStatus,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
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 APStatus = () => {
const { data, send: loadData, error } = useRequest(APApi.readAPStatus);
const { LL } = useI18nContext();
const theme = useTheme();
useLayoutTitle(LL.ACCESS_POINT(0));
useInterval(() => {
void loadData();
});
const { LL } = useI18nContext();
useLayoutTitle(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 || ''} />;
}
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
}
return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
@@ -72,19 +78,26 @@ const APStatus = () => {
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={apStatus(data)} />
<ListItemText
primary={LL.STATUS_OF('')}
secondary={getApStatusText(data.status, LL)}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>IP</Avatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>IP</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.ADDRESS_OF('IP')} secondary={data.ip_address} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
@@ -93,21 +106,22 @@ const APStatus = () => {
secondary={data.mac_address}
/>
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar>
<Avatar sx={{ bgcolor: 'primary.main' }}>
<ComputerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.AP_CLIENTS()} secondary={data.station_num} />
</ListItem>
<Divider variant="inset" component="li" />
</List>
);
};
return <SectionContent>{content()}</SectionContent>;
</SectionContent>
);
};
export default APStatus;

View File

@@ -1,3 +1,5 @@
import { useCallback, useMemo } from 'react';
import {
Body,
Cell,
@@ -17,6 +19,12 @@ import { useInterval } from 'utils';
import { readActivity } from '../../api/app';
import type { Stat } from '../main/types';
const QUALITY_COLORS = {
PERFECT: '#00FF7F',
WARNING: 'orange',
POOR: 'red'
} as const;
const SystemActivity = () => {
const { data, send: loadData, error } = useRequest(readActivity);
@@ -28,14 +36,16 @@ const SystemActivity = () => {
useLayoutTitle(LL.DATA_TRAFFIC());
const stats_theme = tableTheme({
Table: `
const stats_theme = tableTheme(
useMemo(
() => ({
Table: `
--data-table-library_grid-template-columns: repeat(1, minmax(0, 1fr)) 90px 90px 80px;
`,
BaseRow: `
BaseRow: `
font-size: 14px;
`,
HeaderRow: `
HeaderRow: `
text-transform: uppercase;
background-color: black;
color: #90CAF9;
@@ -45,7 +55,7 @@ const SystemActivity = () => {
border-bottom: 1px solid #565656;
}
`,
Row: `
Row: `
.td {
padding: 8px;
border-top: 1px solid #565656;
@@ -59,34 +69,40 @@ const SystemActivity = () => {
background-color: #1e1e1e;
}
`,
BaseCell: `
BaseCell: `
&:not(:first-of-type) {
text-align: center;
}
`
});
}),
[]
)
);
const showName = (id: number) => {
const name: keyof Translation['STATUS_NAMES'] =
id.toString() as keyof Translation['STATUS_NAMES'];
return LL.STATUS_NAMES[name]();
};
const showName = useCallback(
(id: number) => {
const name: keyof Translation['STATUS_NAMES'] =
id.toString() as keyof Translation['STATUS_NAMES'];
return LL.STATUS_NAMES[name]();
},
[LL]
);
const showQuality = (stat: Stat) => {
const showQuality = useCallback((stat: Stat) => {
if (stat.q === 0 || stat.s + stat.f === 0) {
return;
}
if (stat.q === 100) {
return <div style={{ color: '#00FF7F' }}>{stat.q}%</div>;
return <div style={{ color: QUALITY_COLORS.PERFECT }}>{stat.q}%</div>;
}
if (stat.q >= 95) {
return <div style={{ color: 'orange' }}>{stat.q}%</div>;
return <div style={{ color: QUALITY_COLORS.WARNING }}>{stat.q}%</div>;
} else {
return <div style={{ color: 'red' }}>{stat.q}%</div>;
return <div style={{ color: QUALITY_COLORS.POOR }}>{stat.q}%</div>;
}
};
}, []);
const content = () => {
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
@@ -121,9 +137,9 @@ const SystemActivity = () => {
)}
</Table>
);
};
}, [data, loadData, error?.message, stats_theme, LL, showName, showQuality]);
return <SectionContent>{content()}</SectionContent>;
return <SectionContent>{content}</SectionContent>;
};
export default SystemActivity;

View File

@@ -1,3 +1,5 @@
import { ReactElement } from 'react';
import AppsIcon from '@mui/icons-material/Apps';
import DeveloperBoardIcon from '@mui/icons-material/DeveloperBoard';
import DevicesIcon from '@mui/icons-material/Devices';
@@ -24,10 +26,61 @@ import { useInterval } from 'utils';
import BBQKeesIcon from './bbqkees.svg';
// Constants
const AVATAR_COLORS = {
DEFAULT: '#5f9a5f',
BBQKEES: '#003289'
} as const;
const TEMP_THRESHOLD_CELSIUS = 90; // Temperature threshold to determine F vs C
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
function formatTemperature(temp?: number): string {
if (!temp) return '';
const unit = temp > TEMP_THRESHOLD_CELSIUS ? 'F' : 'C';
return `, T: ${temp} °${unit}`;
}
function formatFlashSpeed(speed: number): string {
return (speed / 1000000).toFixed(0) + ' MHz';
}
function formatCPUCores(cores: number): string {
return cores === 1 ? 'single-core)' : 'dual-core)';
}
// Reusable component for hardware status list items
interface HardwareListItemProps {
icon: ReactElement;
primary: string;
secondary: string;
avatarColor?: string;
customIcon?: ReactElement | undefined;
}
const HardwareListItem = ({
icon,
primary,
secondary,
avatarColor = AVATAR_COLORS.DEFAULT,
customIcon
}: HardwareListItemProps) => (
<>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: avatarColor, color: 'white' }}>
{customIcon || icon}
</Avatar>
</ListItemAvatar>
<ListItemText primary={primary} secondary={secondary} />
</ListItem>
<Divider variant="inset" component="li" />
</>
);
const HardwareStatus = () => {
const { LL } = useI18nContext();
@@ -39,175 +92,72 @@ const HardwareStatus = () => {
void loadData();
});
const content = () => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
if (!data) {
return (
<List>
<ListItem>
<ListItemAvatar>
{data.model ? (
<Avatar sx={{ bgcolor: '#003289', color: 'white' }}>
<img
alt="BBQKees"
src={BBQKeesIcon}
style={{ width: 16, verticalAlign: 'middle' }}
/>
</Avatar>
) : (
<Avatar sx={{ bgcolor: '#5f9a5f', color: 'white' }}>
<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' +
// bit of a hack : if the CPU temp is higher than 90 (=32 Fahrenheit if using Celsius), show F, otherwise C
(data.temperature
? ', T: ' +
data.temperature +
' °' +
(data.temperature > 90 ? 'F' : 'C')
: '')
}
/>
</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, ' +
formatNumber(data.free_caps) +
' KB caps)'
}
/>
</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>
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={error?.message || ''} />
</SectionContent>
);
};
}
return <SectionContent>{content()}</SectionContent>;
return (
<SectionContent>
<List>
<HardwareListItem
icon={<TapAndPlayIcon />}
primary={`${LL.HARDWARE()} ${LL.DEVICE()}`}
secondary={data.model || data.cpu_type}
avatarColor={data.model ? AVATAR_COLORS.BBQKEES : AVATAR_COLORS.DEFAULT}
customIcon={
data.model ? (
<img
alt="BBQKees"
src={BBQKeesIcon}
style={{ width: 16, verticalAlign: 'middle' }}
/>
) : undefined
}
/>
<HardwareListItem
icon={<DevicesIcon />}
primary="SDK"
secondary={`${data.arduino_version} / ESP-IDF ${data.sdk_version}`}
/>
<HardwareListItem
icon={<DeveloperBoardIcon />}
primary="CPU"
secondary={`${data.esp_platform}/${data.cpu_type} (rev.${data.cpu_rev}, ${formatCPUCores(data.cpu_cores)} @ ${data.cpu_freq_mhz} Mhz${formatTemperature(data.temperature)}`}
/>
<HardwareListItem
icon={<MemoryIcon />}
primary={LL.FREE_MEMORY()}
secondary={`${formatNumber(data.free_heap)} KB (${formatNumber(data.max_alloc_heap)} KB max alloc, ${formatNumber(data.free_caps)} KB caps)`}
/>
{data.psram_size !== undefined && data.free_psram !== undefined && (
<HardwareListItem
icon={<AppsIcon />}
primary={LL.PSRAM()}
secondary={`${formatNumber(data.psram_size)} KB / ${formatNumber(data.free_psram)} KB`}
/>
)}
<HardwareListItem
icon={<SdStorageIcon />}
primary={LL.FLASH()}
secondary={`${formatNumber(data.flash_chip_size)} KB , ${formatFlashSpeed(data.flash_chip_speed)}`}
/>
<HardwareListItem
icon={<SdCardAlertIcon />}
primary={LL.APPSIZE()}
secondary={`${data.partition}: ${formatNumber(data.app_used)} KB / ${formatNumber(data.app_free)} KB`}
/>
<HardwareListItem
icon={<FolderIcon />}
primary={LL.FILESYSTEM()}
secondary={`${formatNumber(data.fs_used)} KB / ${formatNumber(data.fs_free)} KB`}
/>
</List>
</SectionContent>
);
};
export default HardwareStatus;

View File

@@ -1,3 +1,5 @@
import { type FC, memo, useMemo } from 'react';
import AutoAwesomeMotionIcon from '@mui/icons-material/AutoAwesomeMotion';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ReportIcon from '@mui/icons-material/Report';
@@ -22,17 +24,28 @@ import type { MqttStatusType } from 'types';
import { MqttDisconnectReason } from 'types';
import { useInterval } from 'utils';
// Disconnect reason lookup table - created once, reused across renders
const DISCONNECT_REASONS: Record<MqttDisconnectReason, string> = {
[MqttDisconnectReason.USER_OK]: 'User disconnected',
[MqttDisconnectReason.TCP_DISCONNECTED]: 'TCP disconnected',
[MqttDisconnectReason.MQTT_UNACCEPTABLE_PROTOCOL_VERSION]:
'Unacceptable protocol version',
[MqttDisconnectReason.MQTT_IDENTIFIER_REJECTED]: 'Client ID rejected',
[MqttDisconnectReason.MQTT_SERVER_UNAVAILABLE]: 'Server unavailable',
[MqttDisconnectReason.MQTT_MALFORMED_CREDENTIALS]: 'Malformed credentials',
[MqttDisconnectReason.MQTT_NOT_AUTHORIZED]: 'Not authorized',
[MqttDisconnectReason.TLS_BAD_FINGERPRINT]: 'TLS fingerprint invalid'
};
const getDisconnectReason = (disconnect_reason: MqttDisconnectReason): string =>
DISCONNECT_REASONS[disconnect_reason] ?? 'Unknown';
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;
if (!enabled) return theme.palette.info.main;
return connected ? theme.palette.success.main : theme.palette.error.main;
};
export const mqttPublishHighlight = (
@@ -41,114 +54,100 @@ export const mqttPublishHighlight = (
) => {
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;
export const mqttQueueHighlight = ({ mqtt_queued }: MqttStatusType, theme: Theme) =>
mqtt_queued <= 1 ? theme.palette.success.main : theme.palette.warning.main;
return theme.palette.warning.main;
};
interface ConnectionStatusProps {
data: MqttStatusType;
theme: Theme;
}
// Memoized component to prevent unnecessary re-renders when parent updates
const ConnectionStatus: FC<ConnectionStatusProps> = memo(({ data, theme }) => {
const { LL } = useI18nContext();
return (
<>
{!data.connected && (
<>
<ListItem>
<ListItemAvatar>
<Avatar>
<ReportIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.DISCONNECT_REASON()}
secondary={getDisconnectReason(data.disconnect_reason)}
/>
</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" />
</>
);
});
const MqttStatus = () => {
const { data, send: loadData, error } = useRequest(MqttApi.readMqttStatus);
const { LL } = useI18nContext();
const theme = useTheme();
useLayoutTitle('MQTT');
useInterval(() => {
void loadData();
});
const { LL } = useI18nContext();
useLayoutTitle('MQTT');
// Memoize error message separately to avoid re-renders on error object changes
const errorMessage = error?.message || '';
const theme = useTheme();
const mqttStatus = ({ enabled, connected, connect_count }: MqttStatusType) => {
if (!enabled) {
return LL.NOT_ENABLED();
}
if (connected) {
return LL.CONNECTED(0) + ' (' + connect_count + ')';
}
return LL.DISCONNECTED() + ' (' + 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" />
</>
);
const mqttStatusText = useMemo(() => {
if (!data) return '';
if (!data.enabled) return LL.NOT_ENABLED();
return data.connected
? `${LL.CONNECTED(0)} (${data.connect_count})`
: `${LL.DISCONNECTED()} (${data.connect_count})`;
}, [data, LL]);
if (!data) {
return (
<SectionContent>
<FormLoader onRetry={loadData} errorMessage={errorMessage} />
</SectionContent>
);
}
return (
<SectionContent>
<List>
<ListItem>
<ListItemAvatar>
@@ -156,15 +155,13 @@ const MqttStatus = () => {
<DeviceHubIcon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatus(data)} />
<ListItemText primary={LL.STATUS_OF('')} secondary={mqttStatusText} />
</ListItem>
<Divider variant="inset" component="li" />
{data.enabled && renderConnectionStatus()}
{data.enabled && <ConnectionStatus data={data} theme={theme} />}
</List>
);
};
return <SectionContent>{content()}</SectionContent>;
</SectionContent>
);
};
export default MqttStatus;

View File

@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
import DnsIcon from '@mui/icons-material/Dns';
import SwapVerticalCircleIcon from '@mui/icons-material/SwapVerticalCircle';
@@ -23,6 +25,23 @@ import { NTPSyncStatus } from 'types';
import { useInterval } from 'utils';
import { formatDateTime } from 'utils';
// Utility functions
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 NTPStatus = () => {
const { data, send: loadData, error } = useRequest(NTPApi.readNTPStatus);
@@ -33,24 +52,6 @@ const NTPStatus = () => {
const { LL } = useI18nContext();
useLayoutTitle('NTP');
NTPApi.updateTime;
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 theme = useTheme();
const ntpStatus = ({ status }: NTPStatusType) => {
@@ -66,66 +67,64 @@ const NTPStatus = () => {
}
};
const content = () => {
const content = useMemo(() => {
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>
</>
<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>
);
};
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content()}</SectionContent>;
return <SectionContent>{content}</SectionContent>;
};
export default NTPStatus;

View File

@@ -1,3 +1,5 @@
import { useMemo } from 'react';
import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DnsIcon from '@mui/icons-material/Dns';
import GiteIcon from '@mui/icons-material/Gite';
@@ -25,10 +27,17 @@ import type { NetworkStatusType } from 'types';
import { NetworkConnectionStatus } from 'types';
import { useInterval } from 'utils';
// Utility functions
const isConnected = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED ||
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
export const isWiFi = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED;
export const isEthernet = ({ status }: NetworkStatusType) =>
status === NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED;
const networkStatusHighlight = ({ status }: NetworkStatusType, theme: Theme) => {
switch (status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
@@ -55,11 +64,6 @@ const networkQualityHighlight = ({ rssi }: NetworkStatusType, theme: Theme) => {
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';
@@ -81,6 +85,33 @@ const IPs = (status: NetworkStatusType) => {
return status.local_ip + ', ' + status.local_ipv6;
};
const getNetworkStatusText = (
status: NetworkConnectionStatus,
reconnectCount: number,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
switch (status) {
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
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) (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED(0) + ' (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST() + ' (' + reconnectCount + ')';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const NetworkStatus = () => {
const { data, send: loadData, error } = useRequest(NetworkApi.readNetworkStatus);
@@ -93,51 +124,30 @@ const NetworkStatus = () => {
const theme = useTheme();
const networkStatus = ({ status }: NetworkStatusType) => {
switch (status) {
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
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.reconnect_count + ')';
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return (
LL.CONNECTED(1) + ' ' + LL.FAILED(0) + ' (' + data.reconnect_count + ')'
);
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST() + ' (' + data.reconnect_count + ')';
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
const content = () => {
const content = useMemo(() => {
if (!data) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
const statusText = getNetworkStatusText(data.status, data.reconnect_count, LL);
const statusColor = networkStatusHighlight(data, theme);
const qualityColor = networkQualityHighlight(data, theme);
return (
<List>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
<Avatar sx={{ bgcolor: statusColor }}>
{isWiFi(data) && <WifiIcon />}
{isEthernet(data) && <RouterIcon />}
</Avatar>
</ListItemAvatar>
<ListItemText primary="Status" secondary={networkStatus(data)} />
<ListItemText primary="Status" secondary={statusText} />
</ListItem>
<Divider variant="inset" component="li" />
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkStatusHighlight(data, theme) }}>
<Avatar sx={{ bgcolor: statusColor }}>
<GiteIcon />
</Avatar>
</ListItemAvatar>
@@ -148,13 +158,13 @@ const NetworkStatus = () => {
<>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: networkQualityHighlight(data, theme) }}>
<Avatar sx={{ bgcolor: qualityColor }}>
<SettingsInputAntennaIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary="SSID (RSSI)"
secondary={data.ssid + ' (' + data.rssi + ' dBm)'}
secondary={`${data.ssid} (${data.rssi} dBm)`}
/>
</ListItem>
<Divider variant="inset" component="li" />
@@ -218,9 +228,9 @@ const NetworkStatus = () => {
)}
</List>
);
};
}, [data, error, loadData, LL, theme]);
return <SectionContent>{content()}</SectionContent>;
return <SectionContent>{content}</SectionContent>;
};
export default NetworkStatus;

View File

@@ -1,4 +1,4 @@
import { useContext, useState } from 'react';
import { useCallback, useContext, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime';
@@ -43,6 +43,28 @@ import { formatDateTime } from 'utils/time';
import SystemMonitor from './SystemMonitor';
// Pure functions moved outside component to avoid recreation on each render
const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
const formatDurationSec = (
duration_sec: number,
LL: ReturnType<typeof useI18nContext>['LL']
) => {
const ms = duration_sec * 1000;
const days = Math.trunc(ms / 86400000);
const hours = Math.trunc(ms / 3600000) % 24;
const minutes = Math.trunc(ms / 60000) % 60;
const seconds = Math.trunc(ms / 1000) % 60;
const parts: string[] = [];
if (days) parts.push(LL.NUM_DAYS({ num: days }));
if (hours) parts.push(LL.NUM_HOURS({ num: hours }));
if (minutes) parts.push(LL.NUM_MINUTES({ num: minutes }));
parts.push(LL.NUM_SECONDS({ num: seconds }));
return parts.join(' ');
};
const SystemStatus = () => {
const { LL } = useI18nContext();
@@ -76,51 +98,25 @@ const SystemStatus = () => {
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;
// Memoize derived status values to avoid recalculation on every render
const busStatus = useMemo(() => {
if (!data) return 'EMS state unknown';
let formatted = '';
if (days) {
formatted += LL.NUM_DAYS({ num: days }) + ' ';
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED:
return `EMS ${LL.CONNECTED(0)} (${formatDurationSec(data.bus_uptime, LL)})`;
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return 'EMS ' + LL.TX_ISSUES();
case busConnectionStatus.BUS_STATUS_OFFLINE:
return 'EMS ' + LL.DISCONNECTED();
default:
return 'EMS state unknown';
}
if (hours) {
formatted += LL.NUM_HOURS({ num: hours }) + ' ';
}
if (minutes) {
formatted += LL.NUM_MINUTES({ num: minutes }) + ' ';
}
formatted += LL.NUM_SECONDS({ num: seconds });
return formatted;
};
}, [data?.bus_status, data?.bus_uptime, LL]);
function formatNumber(num: number) {
return new Intl.NumberFormat().format(num);
}
const busStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main;
const busStatus = () => {
if (data) {
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_CONNECTED:
return (
'EMS ' +
LL.CONNECTED(0) +
' (' +
formatDurationSec(data.bus_uptime) +
')'
);
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return 'EMS ' + LL.TX_ISSUES();
case busConnectionStatus.BUS_STATUS_OFFLINE:
return 'EMS ' + LL.DISCONNECTED();
}
}
return 'EMS state unknown';
};
const busStatusHighlight = () => {
switch (data.bus_status) {
case busConnectionStatus.BUS_STATUS_TX_ERRORS:
return theme.palette.warning.main;
@@ -131,27 +127,28 @@ const SystemStatus = () => {
default:
return theme.palette.warning.main;
}
};
}, [data?.bus_status, theme.palette]);
const ntpStatus = useMemo(() => {
if (!data) return LL.UNKNOWN();
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() +
(data.ntp_time !== undefined
? ' (' + formatDateTime(data.ntp_time) + ')'
: '')
);
return data.ntp_time
? `${LL.ACTIVE()} (${formatDateTime(data.ntp_time)})`
: LL.ACTIVE();
default:
return LL.UNKNOWN();
}
};
}, [data?.ntp_status, data?.ntp_time, LL]);
const ntpStatusHighlight = useMemo(() => {
if (!data) return theme.palette.error.main;
const ntpStatusHighlight = () => {
switch (data.ntp_status) {
case NTPSyncStatus.NTP_DISABLED:
return theme.palette.info.main;
@@ -162,9 +159,11 @@ const SystemStatus = () => {
default:
return theme.palette.error.main;
}
};
}, [data?.ntp_status, theme.palette]);
const networkStatusHighlight = useMemo(() => {
if (!data) return theme.palette.warning.main;
const networkStatusHighlight = () => {
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_IDLE:
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
@@ -179,9 +178,11 @@ const SystemStatus = () => {
default:
return theme.palette.warning.main;
}
};
}, [data?.network_status, theme.palette]);
const networkStatus = useMemo(() => {
if (!data) return LL.UNKNOWN();
const networkStatus = () => {
switch (data.network_status) {
case NetworkConnectionStatus.WIFI_STATUS_NO_SHIELD:
return LL.INACTIVE(1);
@@ -190,24 +191,27 @@ const SystemStatus = () => {
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)';
return `${LL.CONNECTED(0)} (WiFi, ${data.wifi_rssi} dBm)`;
case NetworkConnectionStatus.ETHERNET_STATUS_CONNECTED:
return LL.CONNECTED(0) + ' (Ethernet)';
return `${LL.CONNECTED(0)} (Ethernet)`;
case NetworkConnectionStatus.WIFI_STATUS_CONNECT_FAILED:
return LL.CONNECTED(1) + ' ' + LL.FAILED(0);
return `${LL.CONNECTED(1)} ${LL.FAILED(0)}`;
case NetworkConnectionStatus.WIFI_STATUS_CONNECTION_LOST:
return LL.CONNECTED(1) + ' ' + LL.LOST();
return `${LL.CONNECTED(1)} ${LL.LOST()}`;
case NetworkConnectionStatus.WIFI_STATUS_DISCONNECTED:
return LL.DISCONNECTED();
default:
return LL.UNKNOWN();
}
};
}, [data?.network_status, data?.wifi_rssi, LL]);
const activeHighlight = (value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main;
const activeHighlight = useCallback(
(value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main,
[theme.palette]
);
const doRestart = async () => {
const doRestart = useCallback(async () => {
setConfirmRestart(false);
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
@@ -215,38 +219,83 @@ const SystemStatus = () => {
toast.error(error.message);
}
);
};
}, [sendAPI]);
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)}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
const handleCloseRestartDialog = useCallback(() => {
setConfirmRestart(false);
}, []);
const renderRestartDialog = useMemo(
() => (
<Dialog
sx={dialogStyle}
open={confirmRestart}
onClose={handleCloseRestartDialog}
>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleCloseRestartDialog}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
),
[confirmRestart, handleCloseRestartDialog, doRestart, LL]
);
const content = () => {
// Memoize formatted values
const firmwareVersion = useMemo(
() => `v${data?.emsesp_version || ''}`,
[data?.emsesp_version]
);
const uptimeText = useMemo(
() => (data ? formatDurationSec(data.uptime, LL) : ''),
[data?.uptime, LL]
);
const freeMemoryText = useMemo(
() => (data ? `${formatNumber(data.free_heap)} KB ${LL.FREE_MEMORY()}` : ''),
[data?.free_heap, LL]
);
const networkIcon = useMemo(
() =>
data?.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon,
[data?.network_status]
);
const mqttStatusText = useMemo(
() => (data?.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)),
[data?.mqtt_status, LL]
);
const apStatusText = useMemo(
() => (data?.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)),
[data?.ap_status, LL]
);
const handleRestartClick = useCallback(() => {
setConfirmRestart(true);
}, []);
const content = useMemo(() => {
if (!data || !LL) {
return <FormLoader onRetry={loadData} errorMessage={error?.message || ''} />;
}
@@ -258,7 +307,7 @@ const SystemStatus = () => {
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={'v' + data.emsesp_version}
text={firmwareVersion}
to="version"
/>
@@ -268,16 +317,13 @@ const SystemStatus = () => {
<TimerIcon />
</Avatar>
</ListItemAvatar>
<ListItemText
primary={LL.UPTIME()}
secondary={formatDurationSec(data.uptime)}
/>
<ListItemText primary={LL.UPTIME()} secondary={uptimeText} />
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmRestart(true)}
onClick={handleRestartClick}
>
{LL.RESTART()}
</Button>
@@ -289,29 +335,25 @@ const SystemStatus = () => {
icon={MemoryIcon}
bgcolor="#68374d"
label={LL.HARDWARE()}
text={formatNumber(data.free_heap) + ' KB' + ' ' + LL.FREE_MEMORY()}
text={freeMemoryText}
to="/status/hardwarestatus"
/>
<ListMenuItem
disabled={!me.admin}
icon={DirectionsBusIcon}
bgcolor={busStatusHighlight()}
bgcolor={busStatusHighlight}
label={LL.DATA_TRAFFIC()}
text={busStatus()}
text={busStatus}
to="/status/activity"
/>
<ListMenuItem
disabled={!me.admin}
icon={
data.network_status === NetworkConnectionStatus.WIFI_STATUS_CONNECTED
? WifiIcon
: RouterIcon
}
bgcolor={networkStatusHighlight()}
icon={networkIcon}
bgcolor={networkStatusHighlight}
label={LL.NETWORK(1)}
text={networkStatus()}
text={networkStatus}
to="/status/network"
/>
@@ -320,16 +362,16 @@ const SystemStatus = () => {
icon={DeviceHubIcon}
bgcolor={activeHighlight(data.mqtt_status)}
label="MQTT"
text={data.mqtt_status ? LL.CONNECTED(0) : LL.INACTIVE(0)}
text={mqttStatusText}
to="/status/mqtt"
/>
<ListMenuItem
disabled={!me.admin}
icon={AccessTimeIcon}
bgcolor={ntpStatusHighlight()}
bgcolor={ntpStatusHighlight}
label="NTP"
text={ntpStatus()}
text={ntpStatus}
to="/status/ntp"
/>
@@ -338,7 +380,7 @@ const SystemStatus = () => {
icon={SettingsInputAntennaIcon}
bgcolor={activeHighlight(data.ap_status)}
label={LL.ACCESS_POINT(0)}
text={data.ap_status ? LL.ACTIVE() : LL.INACTIVE(0)}
text={apStatusText}
to="/status/ap"
/>
@@ -352,14 +394,33 @@ const SystemStatus = () => {
/>
</List>
{renderRestartDialog()}
{renderRestartDialog}
</>
);
};
}, [
data,
LL,
firmwareVersion,
uptimeText,
freeMemoryText,
networkIcon,
mqttStatusText,
apStatusText,
busStatus,
busStatusHighlight,
networkStatusHighlight,
networkStatus,
ntpStatusHighlight,
ntpStatus,
activeHighlight,
me.admin,
handleRestartClick,
error,
loadData,
renderRestartDialog
]);
return (
<SectionContent>{restarting ? <SystemMonitor /> : content()}</SectionContent>
);
return <SectionContent>{restarting ? <SystemMonitor /> : content}</SectionContent>;
};
export default SystemStatus;

View File

@@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { toast } from 'react-toastify';
import DownloadIcon from '@mui/icons-material/GetApp';
@@ -31,6 +31,8 @@ import type { LogEntry, LogSettings } from 'types';
import { LogLevel } from 'types';
import { updateValueDirty, useRest } from 'utils';
const MAX_LOG_ENTRIES = 1000; // Limit log entries to prevent memory issues
const TextColors: Record<LogLevel, string> = {
[LogLevel.ERROR]: '#ff0000', // red
[LogLevel.WARNING]: '#ff0000', // red
@@ -47,11 +49,6 @@ const LogEntryLine = styled('span')(
})
);
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:
@@ -71,6 +68,36 @@ const levelLabel = (level: LogLevel) => {
}
};
// Memoized log entry component to prevent unnecessary re-renders
const LogEntryItem = memo(
({ entry, compact }: { entry: LogEntry; compact: boolean }) => {
const paddedLevelLabel = (level: LogLevel) => {
const label = levelLabel(level);
return compact ? ' ' + label[0] : label.padStart(8, '\xa0');
};
const paddedNameLabel = (name: string) => {
const label = '[' + name + ']';
return compact ? label : label.padEnd(12, '\xa0');
};
const paddedIDLabel = (id: number) => {
const label = id + ':';
return compact ? label : label.padEnd(7, '\xa0');
};
return (
<div style={{ font: '14px monospace', whiteSpace: 'nowrap' }}>
<span>{entry.t}</span>
<span>{paddedLevelLabel(entry.l)}&nbsp;</span>
<span>{paddedIDLabel(entry.i)} </span>
<span>{paddedNameLabel(entry.n)} </span>
<LogEntryLine details={{ level: entry.l }}>{entry.m}</LogEntryLine>
</div>
);
}
);
const SystemLog = () => {
const { LL } = useI18nContext();
@@ -107,7 +134,7 @@ const SystemLog = () => {
const ALPHA_NUMERIC_DASH_REGEX = /^[a-fA-F0-9 ]+$/;
const updateFormValue = updateValueDirty(
origData,
origData as unknown as Record<string, unknown>,
dirtyFlags,
setDirtyFlags,
updateDataValue as (value: unknown) => void
@@ -121,7 +148,13 @@ const SystemLog = () => {
const rawData = message.data;
const logentry = JSON.parse(rawData) as LogEntry;
if (lastId < logentry.i) {
setLogEntries((log) => [...log, logentry]);
setLogEntries((log) => {
const newLog = [...log, logentry];
// Limit log entries to prevent memory issues
return newLog.length > MAX_LOG_ENTRIES
? newLog.slice(-MAX_LOG_ENTRIES)
: newLog;
});
setLastId(logentry.i);
}
})
@@ -129,27 +162,11 @@ const SystemLog = () => {
toast.error('No connection to Log service');
});
const paddedLevelLabel = (level: LogLevel) => {
const label = levelLabel(level);
return data?.compact ? ' ' + label[0] : label.padStart(8, '\xa0');
};
const onDownload = useCallback(() => {
const result = logEntries
.map((i) => `${i.t} ${levelLabel(i.l)} ${i.i}: [${i.n}] ${i.m}`)
.join('\n');
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',
@@ -159,11 +176,11 @@ const SystemLog = () => {
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
};
}, [logEntries]);
const saveSettings = async () => {
const saveSettings = useCallback(async () => {
await saveData();
};
}, [saveData]);
// handle scrolling
const ref = useRef<HTMLDivElement>(null);
@@ -174,9 +191,9 @@ const SystemLog = () => {
block: 'end'
});
}
}, [logEntries.length]);
}, [logEntries.length, autoscroll]);
const sendReadCommand = () => {
const sendReadCommand = useCallback(() => {
if (readValue === '') {
setReadOpen(!readOpen);
return;
@@ -187,7 +204,17 @@ const SystemLog = () => {
setReadOpen(false);
setReadValue('');
}
};
}, [readValue, readOpen, send]);
// Memoize box positioning to avoid recalculating on every render
const boxPosition = useMemo(() => {
const logWindow = document.getElementById('log-window');
if (!logWindow) {
return { top: 0, left: 0 };
}
const rect = logWindow.getBoundingClientRect();
return { top: rect.bottom, left: rect.left };
}, [data]); // Recalculate only when data changes (settings may affect layout)
const content = () => {
if (!data) {
@@ -332,21 +359,13 @@ const SystemLog = () => {
position: 'absolute',
right: 18,
bottom: 18,
left: () => leftOffset(),
top: () => topOffset(),
left: boxPosition.left,
top: boxPosition.top - 110,
p: 1
}}
>
{logEntries.map((e) => (
<div key={e.i} style={{ font: '14px monospace', whiteSpace: 'nowrap' }}>
<span>{e.t}</span>
<span>{paddedLevelLabel(e.l)}&nbsp;</span>
<span>{paddedIDLabel(e.i)} </span>
<span>{paddedNameLabel(e.n)} </span>
<LogEntryLine details={{ level: e.l }} key={e.i}>
{e.m}
</LogEntryLine>
</div>
<LogEntryItem key={e.i} entry={e} compact={data.compact} />
))}
<div ref={ref} />

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useCallback, useMemo, useRef, useState } from 'react';
import CancelIcon from '@mui/icons-material/Cancel';
import { Box, Button, Dialog, DialogContent, Typography } from '@mui/material';
@@ -17,11 +17,9 @@ import { LinearProgressWithLabel } from '../../components/upload/LinearProgressW
const SystemMonitor = () => {
const [errorMessage, setErrorMessage] = useState<string>();
const hasInitialized = useRef(false);
const { LL } = useI18nContext();
let count = 0;
const { send: setSystemStatus } = useRequest(
(status: string) => callAction({ action: 'systemStatus', param: status }),
{
@@ -32,10 +30,12 @@ const SystemMonitor = () => {
const { data, send } = useRequest(readSystemStatus, {
force: true,
async middleware(_, next) {
if (count++ >= 1) {
// skip first request (1 second) to allow AsyncWS to send its response
await next();
// Skip first request to allow AsyncWS to send its response
if (!hasInitialized.current) {
hasInitialized.current = true;
return; // Don't await next() on first call
}
await next();
}
})
.onSuccess((event) => {
@@ -58,13 +58,41 @@ const SystemMonitor = () => {
void send();
}, 1000); // check every 1 second
const onCancel = async () => {
const { statusMessage, isUploading, progressValue } = useMemo(() => {
const status = data?.status;
let message = '';
if (status && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING) {
message = LL.WAIT_FIRMWARE();
} else if (status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART) {
message = LL.APPLICATION_RESTARTING();
} else if (status === SystemStatusCodes.SYSTEM_STATUS_NORMAL) {
message = LL.RESTARTING_PRE();
} else if (status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD) {
message = 'Upload Failed';
} else {
message = LL.RESTARTING_POST();
}
const uploading =
status !== undefined && status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING;
const progress =
uploading && status
? Math.round(status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING)
: 0;
return {
statusMessage: message,
isUploading: uploading,
progressValue: progress
};
}, [data?.status, LL]);
const onCancel = useCallback(async () => {
setErrorMessage(undefined);
await setSystemStatus(
SystemStatusCodes.SYSTEM_STATUS_NORMAL as unknown as string
);
await setSystemStatus(String(SystemStatusCodes.SYSTEM_STATUS_NORMAL));
document.location.href = '/';
};
}, [setSystemStatus]);
return (
<Dialog fullWidth={true} sx={dialogStyle} open={true}>
@@ -76,15 +104,7 @@ const SystemMonitor = () => {
fontWeight={400}
textAlign="center"
>
{data?.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING
? LL.WAIT_FIRMWARE()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_PENDING_RESTART
? LL.APPLICATION_RESTARTING()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_NORMAL
? LL.RESTARTING_PRE()
: data?.status === SystemStatusCodes.SYSTEM_STATUS_ERROR_UPLOAD
? 'Upload Failed'
: LL.RESTARTING_POST()}
{statusMessage}
</Typography>
{errorMessage ? (
@@ -105,13 +125,9 @@ const SystemMonitor = () => {
<Typography mt={2} variant="h6" fontWeight={400} textAlign="center">
{LL.PLEASE_WAIT()}&hellip;
</Typography>
{data && data.status >= SystemStatusCodes.SYSTEM_STATUS_UPLOADING && (
{isUploading && (
<Box width="100%" pl={2} pr={2} py={2}>
<LinearProgressWithLabel
value={Math.round(
data?.status - SystemStatusCodes.SYSTEM_STATUS_UPLOADING
)}
/>
<LinearProgressWithLabel value={progressValue} />
</Box>
)}
</>