diff --git a/interface/src/app/settings/Settings.tsx b/interface/src/app/settings/Settings.tsx index 7a31f66e7..9aab496b7 100644 --- a/interface/src/app/settings/Settings.tsx +++ b/interface/src/app/settings/Settings.tsx @@ -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(); 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 ; } @@ -65,6 +94,15 @@ const Settings = () => { return ( + + { + + {LL.RESTART()} + {LL.RESTART_CONFIRM()} + + + + + + { display: 'flex', justifyContent: 'flex-end', flexWrap: 'nowrap', - whiteSpace: 'nowrap' + whiteSpace: 'nowrap', + gap: 1 }} > + - )} { to="/status/log" /> - - - {LL.RESTART()} - {LL.RESTART_CONFIRM()} - - - - - ); }; diff --git a/interface/src/app/status/Version.tsx b/interface/src/app/status/Version.tsx index a41539053..490566587 100644 --- a/interface/src/app/status/Version.tsx +++ b/interface/src/app/status/Version.tsx @@ -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(); - const [latestDevVersion, setLatestDevVersion] = useState(); + const { me, versions } = useContext(AuthenticatedContext); const [restarting, setRestarting] = useState(false); const [openInstallDialog, setOpenInstallDialog] = useState(false); @@ -447,16 +425,30 @@ const Version = () => { const [openInstallPartitionDialog, setOpenInstallPartitionDialog] = useState(false); - const [usingDevVersion, setUsingDevVersion] = useState(false); const [fetchDevVersion, setFetchDevVersion] = useState(false); - const [devUpgradeAvailable, setDevUpgradeAvailable] = useState(false); - const [stableUpgradeAvailable, setStableUpgradeAvailable] = - useState(false); - const [internetLive, setInternetLive] = useState(false); const [downloadOnly, setDownloadOnly] = useState(false); const [showVersionInfo, setShowVersionInfo] = useState(0); // 1 = stable, 2 = dev, 3 = partition const [firmwareSize, setFirmwareSize] = useState(0); + const latestVersion = useMemo( + () => + versions?.stable + ? { version: versions.stable.version, date: versions.stable.date } + : undefined, + [versions?.stable] + ); + const latestDevVersion = useMemo( + () => + 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 ( ); }; diff --git a/interface/src/components/layout/LayoutMenu.tsx b/interface/src/components/layout/LayoutMenu.tsx index 48ce3e626..5d52921a6 100644 --- a/interface/src/components/layout/LayoutMenu.tsx +++ b/interface/src/components/layout/LayoutMenu.tsx @@ -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} /> diff --git a/interface/src/components/layout/LayoutMenuItem.tsx b/interface/src/components/layout/LayoutMenuItem.tsx index 2ec78433f..5cdbf8c58 100644 --- a/interface/src/components/layout/LayoutMenuItem.tsx +++ b/interface/src/components/layout/LayoutMenuItem.tsx @@ -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 = ({ {label} + {badge && ( + + )} ); }; diff --git a/interface/src/components/layout/ListMenuItem.tsx b/interface/src/components/layout/ListMenuItem.tsx index db92093de..c78d02ac2 100644 --- a/interface/src/components/layout/ListMenuItem.tsx +++ b/interface/src/components/layout/ListMenuItem.tsx @@ -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 = () => ( + +); + const RenderIcon = memo( - ({ icon: Icon, bgcolor, label, text }: ListMenuItemProps) => ( + ({ icon: Icon, bgcolor, label, text, badge }: ListMenuItemProps) => ( <> - + + {label} + {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 })} /> @@ -75,6 +104,7 @@ const LayoutMenuItem = ({ {...(bgcolor && { bgcolor })} label={label} text={text} + {...(badge && { badge })} /> )} diff --git a/interface/src/contexts/authentication/Authentication.tsx b/interface/src/contexts/authentication/Authentication.tsx index 84b41e701..669f06e6a 100644 --- a/interface/src/contexts/authentication/Authentication.tsx +++ b/interface/src/contexts/authentication/Authentication.tsx @@ -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 = ({ children }) => { const [initialized, setInitialized] = useState(false); const [me, setMe] = useState(); + const [versions, setVersions] = useState(); 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 = ({ children }) => { const signOut = (doRedirect: boolean) => { AuthenticationApi.clearAccessToken(); setMe(undefined); + setVersions(undefined); if (doRedirect) { redirect('/'); } @@ -49,8 +68,9 @@ const Authentication: FC = ({ 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 = ({ 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 = ({ children }) => { signIn, signOut, refresh, - ...(me && { me }) + refreshVersions, + ...(me && { me }), + ...(versions && { versions }) }), - [signIn, signOut, me, refresh] + [signIn, signOut, me, refresh, refreshVersions, versions] ); if (initialized) { diff --git a/interface/src/contexts/authentication/context.ts b/interface/src/contexts/authentication/context.ts index d58b320ff..1717bfaef 100644 --- a/interface/src/contexts/authentication/context.ts +++ b/interface/src/contexts/authentication/context.ts @@ -1,12 +1,14 @@ import { createContext } from 'react'; -import type { Me } from 'types'; +import type { Me, VersionsResponse } from 'types'; export interface AuthenticationContextValue { refresh: () => Promise; signIn: (accessToken: string) => void; signOut: (redirect: boolean) => void; me?: Me; + versions?: VersionsResponse; + refreshVersions: () => Promise; } const AuthenticationContextDefaultValue = {} as AuthenticationContextValue; diff --git a/interface/src/types/index.ts b/interface/src/types/index.ts index 8c2f8760c..a4e8726d9 100644 --- a/interface/src/types/index.ts +++ b/interface/src/types/index.ts @@ -7,3 +7,4 @@ export * from './ntp'; export * from './security'; export * from './signin'; export * from './system'; +export * from './versions'; diff --git a/interface/src/types/versions.ts b/interface/src/types/versions.ts new file mode 100644 index 000000000..6f081c613 --- /dev/null +++ b/interface/src/types/versions.ts @@ -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; +} diff --git a/mock-api/restServer.ts b/mock-api/restServer.ts index f8cb3bd15..774b92543 100644 --- a/mock-api/restServer.ts +++ b/mock-api/restServer.ts @@ -144,10 +144,10 @@ let LATEST_STABLE_VERSION = '3.8.2'; let LATEST_DEV_VERSION = '3.8.3-dev.2'; // 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 = 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 switch (version_test as number) { @@ -419,15 +419,25 @@ function upgradeImportantMessages(version: string) { const MOCK_OFFLINE = false; function get_versions() { const isDev = THIS_VERSION.includes('dev'); + const currentUpgradeable = + !MOCK_OFFLINE && + (isDev ? DEV_VERSION_IS_UPGRADEABLE : STABLE_VERSION_IS_UPGRADEABLE); + 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 }; dev?: { version: string; date: string; upgradeable: boolean }; } = { current: { version: THIS_VERSION, type: isDev ? 'dev' : 'stable', - date: '2026-04-25T12:00:00' + date: '2026-04-25T12:00:00', + upgradeable: currentUpgradeable } };