mirror of
https://github.com/emsesp/EMS-ESP32.git
synced 2026-05-02 12:07:02 +00:00
show badge if there is an update available, which is cached
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
23
interface/src/types/versions.ts
Normal file
23
interface/src/types/versions.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user