show badge if there is an update available, which is cached

This commit is contained in:
proddy
2026-04-27 18:12:05 +02:00
parent 6473c55317
commit 6e76bcc9af
11 changed files with 240 additions and 168 deletions

View File

@@ -1,10 +1,13 @@
import { useState } from 'react';
import { useContext, useState } from 'react';
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 ImportExportIcon from '@mui/icons-material/ImportExport';
import LockIcon from '@mui/icons-material/Lock';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
@@ -28,15 +31,23 @@ import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types';
import { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import SystemMonitor from '../status/SystemMonitor';
const Settings = () => {
const { LL } = useI18nContext();
const { versions } = useContext(AuthenticatedContext);
useLayoutTitle(LL.SETTINGS(0));
const firmwareText = versions?.current?.version
? `v${versions.current.version}`
: '';
const upgradeAvailable = versions?.current?.upgradeable ?? false;
const [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
const [confirmRestart, setConfirmRestart] = useState(false);
const [restarting, setRestarting] = useState<boolean>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
@@ -50,6 +61,16 @@ const Settings = () => {
});
};
const doRestart = async () => {
setConfirmRestart(false);
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
const handleFactoryResetClose = () => {
setConfirmFactoryReset(false);
};
@@ -58,6 +79,14 @@ const Settings = () => {
setConfirmFactoryReset(true);
};
const handleRestartClose = () => {
setConfirmRestart(false);
};
const handleRestartClick = () => {
setConfirmRestart(true);
};
if (restarting) {
return <SystemMonitor />;
}
@@ -65,6 +94,15 @@ const Settings = () => {
return (
<SectionContent>
<List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareText}
to="/status/version"
badge={upgradeAvailable}
/>
<ListMenuItem
icon={TuneIcon}
bgcolor="#134ba2"
@@ -156,6 +194,29 @@ const Settings = () => {
</DialogActions>
</Dialog>
<Dialog sx={dialogStyle} open={confirmRestart} onClose={handleRestartClose}>
<DialogTitle>{LL.RESTART()}</DialogTitle>
<DialogContent dividers>{LL.RESTART_CONFIRM()}</DialogContent>
<DialogActions>
<Button
startIcon={<CancelIcon />}
variant="outlined"
onClick={handleRestartClose}
color="secondary"
>
{LL.CANCEL()}
</Button>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={doRestart}
color="error"
>
{LL.RESTART()}
</Button>
</DialogActions>
</Dialog>
<Divider />
<Box
@@ -164,9 +225,18 @@ const Settings = () => {
display: 'flex',
justifyContent: 'flex-end',
flexWrap: 'nowrap',
whiteSpace: 'nowrap'
whiteSpace: 'nowrap',
gap: 1
}}
>
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={handleRestartClick}
color="error"
>
{LL.RESTART()}
</Button>
<Button
startIcon={<SettingsBackupRestoreIcon />}
variant="outlined"

View File

@@ -1,25 +1,16 @@
import { useContext, useState } from 'react';
import { toast } from 'react-toastify';
import { useContext } from 'react';
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 MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import WifiIcon from '@mui/icons-material/Wifi';
import {
Avatar,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List,
ListItem,
ListItemAvatar,
@@ -27,12 +18,10 @@ import {
useTheme
} from '@mui/material';
import { API } from 'api/app';
import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client';
import { type APIcall, busConnectionStatus } from 'app/main/types';
import { busConnectionStatus } from 'app/main/types';
import { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
@@ -41,8 +30,6 @@ import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types
import { useInterval } from 'utils';
import { formatDateTime } from 'utils/time';
import SystemMonitor from './SystemMonitor';
const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
const formatDurationSec = (
@@ -71,24 +58,7 @@ const SystemStatus = () => {
const { me } = useContext(AuthenticatedContext);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false);
const [restarting, setRestarting] = useState<boolean>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
const {
data,
send: loadData,
error
} = useRequest(readSystemStatus, {
async middleware(_, next) {
if (!restarting) {
await next();
}
}
});
const { data, send: loadData, error } = useRequest(readSystemStatus);
useInterval(() => {
void loadData();
@@ -217,22 +187,6 @@ const SystemStatus = () => {
const activeHighlight = (value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main;
const doRestart = async () => {
setConfirmRestart(false);
setRestarting(true);
await sendAPI({ device: 'system', cmd: 'restart', id: 0 }).catch(
(error: Error) => {
toast.error(error.message);
}
);
};
const handleCloseRestartDialog = () => setConfirmRestart(false);
if (restarting) {
return <SystemMonitor />;
}
if (!data || !LL) {
return (
<SectionContent>
@@ -244,14 +198,6 @@ const SystemStatus = () => {
return (
<SectionContent>
<List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={`v${data.emsesp_version || ''}`}
to="version"
/>
<ListItem>
<ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
@@ -262,16 +208,6 @@ const SystemStatus = () => {
primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`}
/>
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmRestart(true)}
>
{LL.RESTART()}
</Button>
)}
</ListItem>
<ListMenuItem
@@ -341,33 +277,6 @@ const SystemStatus = () => {
to="/status/log"
/>
</List>
<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>
</SectionContent>
);
};

View File

@@ -1,4 +1,4 @@
import { memo, useContext, useState } from 'react';
import { memo, useContext, useMemo, useState } from 'react';
import { Link } from 'react-router';
import { toast } from 'react-toastify';
@@ -40,6 +40,7 @@ import {
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import type { TranslationFunctions } from 'i18n/i18n-types';
import type { VersionInfo } from 'types';
import { prettyDateTime } from 'utils/time';
// Constants moved outside component to avoid recreation
@@ -70,26 +71,6 @@ interface VersionData {
developer_mode: boolean;
}
interface VersionInfo {
version: string;
date: string;
}
interface RemoteVersionInfo extends VersionInfo {
upgradeable: boolean;
}
interface CurrentVersionInfo extends VersionInfo {
type: 'stable' | 'dev';
}
// Response payload from the `getVersions` action
interface VersionsResponse {
current: CurrentVersionInfo;
stable?: RemoteVersionInfo;
dev?: RemoteVersionInfo;
}
// Memoized components for better performance
const VersionInfoDialog = memo(
({
@@ -432,10 +413,7 @@ const getPlatform = (data: VersionData): string => {
const Version = () => {
const { LL, locale } = useI18nContext();
const { me } = useContext(AuthenticatedContext);
const [latestVersion, setLatestVersion] = useState<VersionInfo>();
const [latestDevVersion, setLatestDevVersion] = useState<VersionInfo>();
const { me, versions } = useContext(AuthenticatedContext);
const [restarting, setRestarting] = useState<boolean>(false);
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
@@ -447,16 +425,30 @@ const Version = () => {
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
useState<boolean>(false);
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false);
const [fetchDevVersion, setFetchDevVersion] = useState<boolean>(false);
const [devUpgradeAvailable, setDevUpgradeAvailable] = useState<boolean>(false);
const [stableUpgradeAvailable, setStableUpgradeAvailable] =
useState<boolean>(false);
const [internetLive, setInternetLive] = useState<boolean>(false);
const [downloadOnly, setDownloadOnly] = useState<boolean>(false);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
const [firmwareSize, setFirmwareSize] = useState<number>(0);
const latestVersion = useMemo<VersionInfo | undefined>(
() =>
versions?.stable
? { version: versions.stable.version, date: versions.stable.date }
: undefined,
[versions?.stable]
);
const latestDevVersion = useMemo<VersionInfo | undefined>(
() =>
versions?.dev
? { version: versions.dev.version, date: versions.dev.date }
: undefined,
[versions?.dev]
);
const usingDevVersion = versions?.current?.type === 'dev';
const stableUpgradeAvailable = versions?.stable?.upgradeable ?? false;
const devUpgradeAvailable = versions?.dev?.upgradeable ?? false;
const internetLive = Boolean(versions?.stable || versions?.dev);
const { send: sendSetPartition } = useRequest(
(partition: string) => callAction({ action: 'setPartition', param: partition }),
{ immediate: false }
@@ -480,32 +472,6 @@ const Version = () => {
{ immediate: false }
);
// fetch latest stable/dev versions via the device. The C++ code makes a call to emsesp.org/versions.json itself
// if the device has no internet, stable/dev are omitted and the internetLive flag is set to false
useRequest(() => callAction({ action: 'getVersions' }))
.onSuccess((event) => {
const versions = event.data as VersionsResponse;
setUsingDevVersion(versions.current?.type === 'dev');
if (versions.stable) {
setLatestVersion({
version: versions.stable.version,
date: versions.stable.date
});
setStableUpgradeAvailable(versions.stable.upgradeable);
}
if (versions.dev) {
setLatestDevVersion({
version: versions.dev.version,
date: versions.dev.date
});
setDevUpgradeAvailable(versions.dev.upgradeable);
}
setInternetLive(Boolean(versions.stable || versions.dev));
})
.onError(() => {
setInternetLive(false);
});
const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false
});
@@ -640,15 +606,33 @@ const Version = () => {
if (!me.admin) return null;
const isUpdateAvailable = choice === LL.UPDATE_AVAILABLE();
return (
<Button
sx={{ ml: 1 }}
variant="outlined"
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'}
color={isUpdateAvailable ? 'success' : 'warning'}
size="small"
onClick={() => showFirmwareDialog(showingDev)}
>
{choice}
{isUpdateAvailable && (
<Box
component="span"
aria-label="update available"
sx={{
display: 'inline-block',
width: 8,
height: 8,
ml: 1,
verticalAlign: 'middle',
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
}}
/>
)}
</Button>
);
};

View File

@@ -18,10 +18,12 @@ import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react';
const LayoutMenuComponent = () => {
const { me } = useContext(AuthenticatedContext);
const { me, versions } = useContext(AuthenticatedContext);
const { LL } = useI18nContext();
const [menuOpen, setMenuOpen] = useState(true);
const upgradeAvailable = versions?.current?.upgradeable ?? false;
const handleMenuToggle = () => {
setMenuOpen((prev) => !prev);
};
@@ -105,6 +107,7 @@ const LayoutMenuComponent = () => {
label={LL.SETTINGS(0)}
disabled={!me.admin}
to="/settings"
badge={upgradeAvailable}
/>
<LayoutMenuItem icon={LiveHelpIcon} label={LL.HELP()} to={`/help`} />
<Divider />

View File

@@ -1,7 +1,7 @@
import { memo } from 'react';
import { Link, useLocation } from 'react-router';
import { ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import { Box, ListItemButton, ListItemIcon, ListItemText } from '@mui/material';
import type { SvgIconProps, SxProps, Theme } from '@mui/material';
import { routeMatches } from 'utils';
@@ -11,13 +11,15 @@ interface LayoutMenuItemProps {
label: string;
to: string;
disabled?: boolean;
badge?: boolean;
}
const LayoutMenuItemComponent = ({
icon: Icon,
label,
to,
disabled
disabled,
badge
}: LayoutMenuItemProps) => {
const { pathname } = useLocation();
@@ -68,6 +70,20 @@ const LayoutMenuItemComponent = ({
<Icon />
</ListItemIcon>
<ListItemText sx={textStyles}>{label}</ListItemText>
{badge && (
<Box
aria-label="update available"
sx={{
width: 8,
height: 8,
ml: 1,
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)',
flexShrink: 0
}}
/>
)}
</ListItemButton>
);
};

View File

@@ -5,6 +5,7 @@ import { Link } from 'react-router';
import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import {
Avatar,
Box,
ListItem,
ListItemAvatar,
ListItemButton,
@@ -20,6 +21,7 @@ interface ListMenuItemProps {
text: string;
to?: string;
disabled?: boolean;
badge?: boolean;
}
const iconStyles: CSSProperties = {
@@ -28,15 +30,40 @@ const iconStyles: CSSProperties = {
verticalAlign: 'middle'
};
const Badge = () => (
<Box
component="span"
aria-label="update available"
sx={{
display: 'inline-block',
width: 8,
height: 8,
ml: 1,
verticalAlign: 'middle',
borderRadius: '50%',
backgroundColor: '#ffeb3b',
boxShadow: '0 0 6px rgba(255, 235, 59, 0.8)'
}}
/>
);
const RenderIcon = memo(
({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => (
({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => (
<>
<ListItemAvatar>
<Avatar sx={{ bgcolor, color: 'white' }}>
<Icon />
</Avatar>
</ListItemAvatar>
<ListItemText primary={label} secondary={text} />
<ListItemText
primary={
<>
{label}
{badge && <Badge />}
</>
}
secondary={text}
/>
</>
)
);
@@ -47,7 +74,8 @@ const LayoutMenuItem = ({
label,
text,
to,
disabled
disabled,
badge
}: ListMenuItemProps) => (
<>
{to && !disabled ? (
@@ -65,6 +93,7 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })}
label={label}
text={text}
{...(badge && { badge })}
/>
</ListItemButton>
</ListItem>
@@ -75,6 +104,7 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })}
label={label}
text={text}
{...(badge && { badge })}
/>
</ListItem>
)}

View File

@@ -3,6 +3,7 @@ import type { FC } from 'react';
import { redirect } from 'react-router';
import { toast } from 'react-toastify';
import { callAction } from 'api/app';
import { ACCESS_TOKEN } from 'api/endpoints';
import * as AuthenticationApi from 'components/routing/authentication';
@@ -10,7 +11,7 @@ import { useRequest } from 'alova/client';
import { LoadingSpinner } from 'components';
import { verifyAuthorization } from 'components/routing/authentication';
import { useI18nContext } from 'i18n/i18n-react';
import type { Me } from 'types';
import type { Me, VersionsResponse } from 'types';
import type { RequiredChildrenProps } from 'utils';
import { AuthenticationContext } from './context';
@@ -20,17 +21,34 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const [initialized, setInitialized] = useState<boolean>(false);
const [me, setMe] = useState<Me>();
const [versions, setVersions] = useState<VersionsResponse>();
const { send: sendVerifyAuthorization } = useRequest(verifyAuthorization(), {
immediate: false
});
const { send: sendGetVersions } = useRequest(
() => callAction({ action: 'getVersions' }),
{ immediate: false }
)
.onSuccess((event) => {
setVersions(event.data as VersionsResponse);
})
.onError(() => {
setVersions(undefined);
});
const refreshVersions = useCallback(async () => {
await sendGetVersions().catch(() => undefined);
}, []);
const signIn = (accessToken: string) => {
try {
AuthenticationApi.getStorage().setItem(ACCESS_TOKEN, accessToken);
const decodedMe = AuthenticationApi.decodeMeJWT(accessToken);
setMe(decodedMe);
toast.success(LL.LOGGED_IN({ name: decodedMe.username }));
void refreshVersions();
} catch {
setMe(undefined);
throw new Error('Failed to parse JWT');
@@ -40,6 +58,7 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const signOut = (doRedirect: boolean) => {
AuthenticationApi.clearAccessToken();
setMe(undefined);
setVersions(undefined);
if (doRedirect) {
redirect('/');
}
@@ -49,8 +68,9 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
const accessToken = AuthenticationApi.getStorage().getItem(ACCESS_TOKEN);
if (accessToken) {
await sendVerifyAuthorization()
.then(() => {
.then(async () => {
setMe(AuthenticationApi.decodeMeJWT(accessToken));
await refreshVersions();
setInitialized(true);
})
.catch(() => {
@@ -61,6 +81,8 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
setMe(undefined);
setInitialized(true);
}
// refreshVersions and sendVerifyAuthorization are stable
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
@@ -72,9 +94,11 @@ const Authentication: FC<RequiredChildrenProps> = ({ children }) => {
signIn,
signOut,
refresh,
...(me && { me })
refreshVersions,
...(me && { me }),
...(versions && { versions })
}),
[signIn, signOut, me, refresh]
[signIn, signOut, me, refresh, refreshVersions, versions]
);
if (initialized) {

View File

@@ -1,12 +1,14 @@
import { createContext } from 'react';
import type { Me } from 'types';
import type { Me, VersionsResponse } from 'types';
export interface AuthenticationContextValue {
refresh: () => Promise<void>;
signIn: (accessToken: string) => void;
signOut: (redirect: boolean) => void;
me?: Me;
versions?: VersionsResponse;
refreshVersions: () => Promise<void>;
}
const AuthenticationContextDefaultValue = {} as AuthenticationContextValue;

View File

@@ -7,3 +7,4 @@ export * from './ntp';
export * from './security';
export * from './signin';
export * from './system';
export * from './versions';

View File

@@ -0,0 +1,23 @@
// Types for the `getVersions` action response coming from the device.
// The device proxies the request to emsesp.org/versions.json. If the device
// is offline the `stable` and `dev` fields are omitted.
export interface VersionInfo {
version: string;
date: string;
}
export interface RemoteVersionInfo extends VersionInfo {
upgradeable: boolean;
}
export interface CurrentVersionInfo extends VersionInfo {
type: 'stable' | 'dev';
upgradeable: boolean;
}
export interface VersionsResponse {
current: CurrentVersionInfo;
stable?: RemoteVersionInfo;
dev?: RemoteVersionInfo;
}