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 AccessTimeIcon from '@mui/icons-material/AccessTime';
import BuildIcon from '@mui/icons-material/Build';
import CancelIcon from '@mui/icons-material/Cancel'; import CancelIcon from '@mui/icons-material/Cancel';
import DeviceHubIcon from '@mui/icons-material/DeviceHub'; import DeviceHubIcon from '@mui/icons-material/DeviceHub';
import ImportExportIcon from '@mui/icons-material/ImportExport'; import ImportExportIcon from '@mui/icons-material/ImportExport';
import LockIcon from '@mui/icons-material/Lock'; import LockIcon from '@mui/icons-material/Lock';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore'; import SettingsBackupRestoreIcon from '@mui/icons-material/SettingsBackupRestore';
import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet'; import SettingsEthernetIcon from '@mui/icons-material/SettingsEthernet';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
@@ -28,15 +31,23 @@ import { useRequest } from 'alova/client';
import type { APIcall } from 'app/main/types'; import type { APIcall } from 'app/main/types';
import { SectionContent, useLayoutTitle } from 'components'; import { SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem'; import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import SystemMonitor from '../status/SystemMonitor'; import SystemMonitor from '../status/SystemMonitor';
const Settings = () => { const Settings = () => {
const { LL } = useI18nContext(); const { LL } = useI18nContext();
const { versions } = useContext(AuthenticatedContext);
useLayoutTitle(LL.SETTINGS(0)); 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 [confirmFactoryReset, setConfirmFactoryReset] = useState(false);
const [confirmRestart, setConfirmRestart] = useState(false);
const [restarting, setRestarting] = useState<boolean>(); const [restarting, setRestarting] = useState<boolean>();
const { send: sendAPI } = useRequest((data: APIcall) => API(data), { 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 = () => { const handleFactoryResetClose = () => {
setConfirmFactoryReset(false); setConfirmFactoryReset(false);
}; };
@@ -58,6 +79,14 @@ const Settings = () => {
setConfirmFactoryReset(true); setConfirmFactoryReset(true);
}; };
const handleRestartClose = () => {
setConfirmRestart(false);
};
const handleRestartClick = () => {
setConfirmRestart(true);
};
if (restarting) { if (restarting) {
return <SystemMonitor />; return <SystemMonitor />;
} }
@@ -65,6 +94,15 @@ const Settings = () => {
return ( return (
<SectionContent> <SectionContent>
<List> <List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={firmwareText}
to="/status/version"
badge={upgradeAvailable}
/>
<ListMenuItem <ListMenuItem
icon={TuneIcon} icon={TuneIcon}
bgcolor="#134ba2" bgcolor="#134ba2"
@@ -156,6 +194,29 @@ const Settings = () => {
</DialogActions> </DialogActions>
</Dialog> </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 /> <Divider />
<Box <Box
@@ -164,9 +225,18 @@ const Settings = () => {
display: 'flex', display: 'flex',
justifyContent: 'flex-end', justifyContent: 'flex-end',
flexWrap: 'nowrap', flexWrap: 'nowrap',
whiteSpace: 'nowrap' whiteSpace: 'nowrap',
gap: 1
}} }}
> >
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
onClick={handleRestartClick}
color="error"
>
{LL.RESTART()}
</Button>
<Button <Button
startIcon={<SettingsBackupRestoreIcon />} startIcon={<SettingsBackupRestoreIcon />}
variant="outlined" variant="outlined"

View File

@@ -1,25 +1,16 @@
import { useContext, useState } from 'react'; import { useContext } from 'react';
import { toast } from 'react-toastify';
import AccessTimeIcon from '@mui/icons-material/AccessTime'; 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 DeviceHubIcon from '@mui/icons-material/DeviceHub';
import DirectionsBusIcon from '@mui/icons-material/DirectionsBus'; import DirectionsBusIcon from '@mui/icons-material/DirectionsBus';
import LogoDevIcon from '@mui/icons-material/LogoDev'; import LogoDevIcon from '@mui/icons-material/LogoDev';
import MemoryIcon from '@mui/icons-material/Memory'; import MemoryIcon from '@mui/icons-material/Memory';
import MonitorHeartIcon from '@mui/icons-material/MonitorHeart'; import MonitorHeartIcon from '@mui/icons-material/MonitorHeart';
import PowerSettingsNewIcon from '@mui/icons-material/PowerSettingsNew';
import RouterIcon from '@mui/icons-material/Router'; import RouterIcon from '@mui/icons-material/Router';
import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna'; import SettingsInputAntennaIcon from '@mui/icons-material/SettingsInputAntenna';
import WifiIcon from '@mui/icons-material/Wifi'; import WifiIcon from '@mui/icons-material/Wifi';
import { import {
Avatar, Avatar,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
List, List,
ListItem, ListItem,
ListItemAvatar, ListItemAvatar,
@@ -27,12 +18,10 @@ import {
useTheme useTheme
} from '@mui/material'; } from '@mui/material';
import { API } from 'api/app';
import { readSystemStatus } from 'api/system'; import { readSystemStatus } from 'api/system';
import { dialogStyle } from 'CustomTheme';
import { useRequest } from 'alova/client'; 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 { FormLoader, SectionContent, useLayoutTitle } from 'components';
import ListMenuItem from 'components/layout/ListMenuItem'; import ListMenuItem from 'components/layout/ListMenuItem';
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
@@ -41,8 +30,6 @@ import { NTPSyncStatus, NetworkConnectionStatus, SystemStatusCodes } from 'types
import { useInterval } from 'utils'; import { useInterval } from 'utils';
import { formatDateTime } from 'utils/time'; import { formatDateTime } from 'utils/time';
import SystemMonitor from './SystemMonitor';
const formatNumber = (num: number) => new Intl.NumberFormat().format(num); const formatNumber = (num: number) => new Intl.NumberFormat().format(num);
const formatDurationSec = ( const formatDurationSec = (
@@ -71,24 +58,7 @@ const SystemStatus = () => {
const { me } = useContext(AuthenticatedContext); const { me } = useContext(AuthenticatedContext);
const [confirmRestart, setConfirmRestart] = useState<boolean>(false); const { data, send: loadData, error } = useRequest(readSystemStatus);
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();
}
}
});
useInterval(() => { useInterval(() => {
void loadData(); void loadData();
@@ -217,22 +187,6 @@ const SystemStatus = () => {
const activeHighlight = (value: boolean) => const activeHighlight = (value: boolean) =>
value ? theme.palette.success.main : theme.palette.info.main; 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) { if (!data || !LL) {
return ( return (
<SectionContent> <SectionContent>
@@ -244,14 +198,6 @@ const SystemStatus = () => {
return ( return (
<SectionContent> <SectionContent>
<List> <List>
<ListMenuItem
icon={BuildIcon}
bgcolor="#72caf9"
label="EMS-ESP Firmware"
text={`v${data.emsesp_version || ''}`}
to="version"
/>
<ListItem> <ListItem>
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}> <Avatar sx={{ bgcolor: '#c5572c', color: 'white' }}>
@@ -262,16 +208,6 @@ const SystemStatus = () => {
primary={LL.STATUS_OF(LL.SYSTEM(0))} primary={LL.STATUS_OF(LL.SYSTEM(0))}
secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`} secondary={`${systemStatus} (${LL.UPTIME()}: ${formatDurationSec(data.uptime, LL)})`}
/> />
{me.admin && (
<Button
startIcon={<PowerSettingsNewIcon />}
variant="outlined"
color="error"
onClick={() => setConfirmRestart(true)}
>
{LL.RESTART()}
</Button>
)}
</ListItem> </ListItem>
<ListMenuItem <ListMenuItem
@@ -341,33 +277,6 @@ const SystemStatus = () => {
to="/status/log" to="/status/log"
/> />
</List> </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> </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 { Link } from 'react-router';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -40,6 +40,7 @@ import {
import { AuthenticatedContext } from 'contexts/authentication'; import { AuthenticatedContext } from 'contexts/authentication';
import { useI18nContext } from 'i18n/i18n-react'; import { useI18nContext } from 'i18n/i18n-react';
import type { TranslationFunctions } from 'i18n/i18n-types'; import type { TranslationFunctions } from 'i18n/i18n-types';
import type { VersionInfo } from 'types';
import { prettyDateTime } from 'utils/time'; import { prettyDateTime } from 'utils/time';
// Constants moved outside component to avoid recreation // Constants moved outside component to avoid recreation
@@ -70,26 +71,6 @@ interface VersionData {
developer_mode: boolean; 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 // Memoized components for better performance
const VersionInfoDialog = memo( const VersionInfoDialog = memo(
({ ({
@@ -432,10 +413,7 @@ const getPlatform = (data: VersionData): string => {
const Version = () => { const Version = () => {
const { LL, locale } = useI18nContext(); const { LL, locale } = useI18nContext();
const { me } = useContext(AuthenticatedContext); const { me, versions } = useContext(AuthenticatedContext);
const [latestVersion, setLatestVersion] = useState<VersionInfo>();
const [latestDevVersion, setLatestDevVersion] = useState<VersionInfo>();
const [restarting, setRestarting] = useState<boolean>(false); const [restarting, setRestarting] = useState<boolean>(false);
const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false); const [openInstallDialog, setOpenInstallDialog] = useState<boolean>(false);
@@ -447,16 +425,30 @@ const Version = () => {
const [openInstallPartitionDialog, setOpenInstallPartitionDialog] = const [openInstallPartitionDialog, setOpenInstallPartitionDialog] =
useState<boolean>(false); useState<boolean>(false);
const [usingDevVersion, setUsingDevVersion] = useState<boolean>(false);
const [fetchDevVersion, setFetchDevVersion] = 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 [downloadOnly, setDownloadOnly] = useState<boolean>(false);
const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition const [showVersionInfo, setShowVersionInfo] = useState<number>(0); // 1 = stable, 2 = dev, 3 = partition
const [firmwareSize, setFirmwareSize] = useState<number>(0); 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( const { send: sendSetPartition } = useRequest(
(partition: string) => callAction({ action: 'setPartition', param: partition }), (partition: string) => callAction({ action: 'setPartition', param: partition }),
{ immediate: false } { immediate: false }
@@ -480,32 +472,6 @@ const Version = () => {
{ immediate: false } { 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), { const { send: sendAPI } = useRequest((data: APIcall) => API(data), {
immediate: false immediate: false
}); });
@@ -640,15 +606,33 @@ const Version = () => {
if (!me.admin) return null; if (!me.admin) return null;
const isUpdateAvailable = choice === LL.UPDATE_AVAILABLE();
return ( return (
<Button <Button
sx={{ ml: 1 }} sx={{ ml: 1 }}
variant="outlined" variant="outlined"
color={choice === LL.UPDATE_AVAILABLE() ? 'success' : 'warning'} color={isUpdateAvailable ? 'success' : 'warning'}
size="small" size="small"
onClick={() => showFirmwareDialog(showingDev)} onClick={() => showFirmwareDialog(showingDev)}
> >
{choice} {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> </Button>
); );
}; };

View File

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

View File

@@ -1,7 +1,7 @@
import { memo } from 'react'; import { memo } from 'react';
import { Link, useLocation } from 'react-router'; 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 type { SvgIconProps, SxProps, Theme } from '@mui/material';
import { routeMatches } from 'utils'; import { routeMatches } from 'utils';
@@ -11,13 +11,15 @@ interface LayoutMenuItemProps {
label: string; label: string;
to: string; to: string;
disabled?: boolean; disabled?: boolean;
badge?: boolean;
} }
const LayoutMenuItemComponent = ({ const LayoutMenuItemComponent = ({
icon: Icon, icon: Icon,
label, label,
to, to,
disabled disabled,
badge
}: LayoutMenuItemProps) => { }: LayoutMenuItemProps) => {
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -68,6 +70,20 @@ const LayoutMenuItemComponent = ({
<Icon /> <Icon />
</ListItemIcon> </ListItemIcon>
<ListItemText sx={textStyles}>{label}</ListItemText> <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> </ListItemButton>
); );
}; };

View File

@@ -5,6 +5,7 @@ import { Link } from 'react-router';
import NavigateNextIcon from '@mui/icons-material/NavigateNext'; import NavigateNextIcon from '@mui/icons-material/NavigateNext';
import { import {
Avatar, Avatar,
Box,
ListItem, ListItem,
ListItemAvatar, ListItemAvatar,
ListItemButton, ListItemButton,
@@ -20,6 +21,7 @@ interface ListMenuItemProps {
text: string; text: string;
to?: string; to?: string;
disabled?: boolean; disabled?: boolean;
badge?: boolean;
} }
const iconStyles: CSSProperties = { const iconStyles: CSSProperties = {
@@ -28,15 +30,40 @@ const iconStyles: CSSProperties = {
verticalAlign: 'middle' 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( const RenderIcon = memo(
({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => ( ({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => (
<> <>
<ListItemAvatar> <ListItemAvatar>
<Avatar sx={{ bgcolor, color: 'white' }}> <Avatar sx={{ bgcolor, color: 'white' }}>
<Icon /> <Icon />
</Avatar> </Avatar>
</ListItemAvatar> </ListItemAvatar>
<ListItemText primary={label} secondary={text} /> <ListItemText
primary={
<>
{label}
{badge && <Badge />}
</>
}
secondary={text}
/>
</> </>
) )
); );
@@ -47,7 +74,8 @@ const LayoutMenuItem = ({
label, label,
text, text,
to, to,
disabled disabled,
badge
}: ListMenuItemProps) => ( }: ListMenuItemProps) => (
<> <>
{to && !disabled ? ( {to && !disabled ? (
@@ -65,6 +93,7 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })} {...(bgcolor && { bgcolor })}
label={label} label={label}
text={text} text={text}
{...(badge && { badge })}
/> />
</ListItemButton> </ListItemButton>
</ListItem> </ListItem>
@@ -75,6 +104,7 @@ const LayoutMenuItem = ({
{...(bgcolor && { bgcolor })} {...(bgcolor && { bgcolor })}
label={label} label={label}
text={text} text={text}
{...(badge && { badge })}
/> />
</ListItem> </ListItem>
)} )}

View File

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

View File

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

View File

@@ -7,3 +7,4 @@ export * from './ntp';
export * from './security'; export * from './security';
export * from './signin'; export * from './signin';
export * from './system'; 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;
}

View File

@@ -144,10 +144,10 @@ let LATEST_STABLE_VERSION = '3.8.2';
let LATEST_DEV_VERSION = '3.8.3-dev.2'; let LATEST_DEV_VERSION = '3.8.3-dev.2';
// scenarios for testing versioning // scenarios for testing versioning
let version_test = 0; // on latest stable, or switch to dev // let version_test = 0; // on latest stable, or switch to dev
// let version_test = 1; // on latest dev, or switch back to stable // let version_test = 1; // on latest dev, or switch back to stable
// let version_test = 2; // upgrade an older stable to latest stable or switch to latest dev // let version_test = 2; // upgrade an older stable to latest stable or switch to latest dev
// let version_test = 3; // upgrade dev to latest, or switch to stable let version_test = 3; // upgrade dev to latest, or switch to stable
// let version_test = 4; // downgrade to an older dev, or switch back to stable // let version_test = 4; // downgrade to an older dev, or switch back to stable
switch (version_test as number) { switch (version_test as number) {
@@ -419,15 +419,25 @@ function upgradeImportantMessages(version: string) {
const MOCK_OFFLINE = false; const MOCK_OFFLINE = false;
function get_versions() { function get_versions() {
const isDev = THIS_VERSION.includes('dev'); const isDev = THIS_VERSION.includes('dev');
const currentUpgradeable =
!MOCK_OFFLINE &&
(isDev ? DEV_VERSION_IS_UPGRADEABLE : STABLE_VERSION_IS_UPGRADEABLE);
const data: { const data: {
current: { version: string; type: 'stable' | 'dev'; date: string }; current: {
version: string;
type: 'stable' | 'dev';
date: string;
upgradeable: boolean;
};
stable?: { version: string; date: string; upgradeable: boolean }; stable?: { version: string; date: string; upgradeable: boolean };
dev?: { version: string; date: string; upgradeable: boolean }; dev?: { version: string; date: string; upgradeable: boolean };
} = { } = {
current: { current: {
version: THIS_VERSION, version: THIS_VERSION,
type: isDev ? 'dev' : 'stable', type: isDev ? 'dev' : 'stable',
date: '2026-04-25T12:00:00' date: '2026-04-25T12:00:00',
upgradeable: currentUpgradeable
} }
}; };