add update check badge

This commit is contained in:
Sidney Alcantara
2021-11-26 15:30:25 +11:00
parent ccff7500bc
commit 8be3c7a3d3
10 changed files with 211 additions and 173 deletions

View File

@@ -25,6 +25,7 @@
"algoliasearch": "^4.8.6",
"ansi-to-react": "^6.1.5",
"colord": "^2.7.0",
"compare-versions": "^4.1.1",
"craco-swc": "^0.1.3",
"csv-parse": "^4.15.3",
"date-fns": "^2.19.0",

View File

@@ -23,6 +23,7 @@ import { APP_BAR_HEIGHT } from ".";
import Logo from "@src/assets/Logo";
import NavItem from "./NavItem";
import NavTableSection from "./NavTableSection";
import UpdateCheckBadge from "./UpdateCheckBadge";
import { useAppContext } from "@src/contexts/AppContext";
import { useProjectContext } from "@src/contexts/ProjectContext";
@@ -157,6 +158,7 @@ export default function NavDrawer({
<ProjectSettingsIcon />
</ListItemIcon>
<ListItemText primary="Project Settings" />
<UpdateCheckBadge sx={{ mr: 1.5 }} />
</NavItem>
</li>
)}

View File

@@ -0,0 +1,22 @@
import { Badge, BadgeProps } from "@mui/material";
import useUpdateCheck from "@src/hooks/useUpdateCheck";
export default function UpdateCheckBadge(props: Partial<BadgeProps>) {
const [latestUpdate] = useUpdateCheck();
if (!latestUpdate.rowy && !latestUpdate.rowyRun) return <>{props.children}</>;
return (
<Badge
badgeContent=" "
color="error"
variant="dot"
aria-label="Update available"
{...props}
sx={{
"& .MuiBadge-badge": { bgcolor: "#f00" },
...props.sx,
}}
/>
);
}

View File

@@ -19,6 +19,7 @@ import NavDrawer, { NAV_DRAWER_WIDTH } from "./NavDrawer";
import UserMenu from "./UserMenu";
import ErrorBoundary from "@src/components/ErrorBoundary";
import Loading from "@src/components/Loading";
import UpdateCheckBadge from "./UpdateCheckBadge";
import { useAppContext } from "@src/contexts/AppContext";
import useDocumentTitle from "@src/hooks/useDocumentTitle";
@@ -43,7 +44,7 @@ export default function Navigation({
currentSection,
titleTransitionProps,
}: INavigationProps) {
const { projectId } = useAppContext();
const { projectId, userRoles } = useAppContext();
useDocumentTitle(projectId, title);
const [open, setOpen] = useOpenState(false);
@@ -114,7 +115,13 @@ export default function Navigation({
size="large"
edge="start"
>
<MenuIcon />
{userRoles.includes("ADMIN") ? (
<UpdateCheckBadge>
<MenuIcon />
</UpdateCheckBadge>
) : (
<MenuIcon />
)}
</IconButton>
</Grow>
)}

View File

@@ -1,7 +1,3 @@
import { useState, useCallback, useEffect } from "react";
import createPersistedState from "use-persisted-state";
import { differenceInDays } from "date-fns";
import { Grid, Typography, Button, Link, Divider } from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import GitHubIcon from "@mui/icons-material/GitHub";
@@ -11,72 +7,15 @@ import TwitterIcon from "@mui/icons-material/Twitter";
import Logo from "@src/assets/Logo";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import { name, version, repository } from "@root/package.json";
import { name, version } from "@root/package.json";
import { useAppContext } from "@src/contexts/AppContext";
import useUpdateCheck from "@src/hooks/useUpdateCheck";
import { EXTERNAL_LINKS, WIKI_LINKS } from "@src/constants/externalLinks";
const useLastCheckedUpdateState = createPersistedState(
"__ROWY__LAST_CHECKED_UPDATE"
);
export const useLatestUpdateState = createPersistedState(
"__ROWY__LATEST_UPDATE"
);
export default function About() {
const { projectId } = useAppContext();
const [lastCheckedUpdate, setLastCheckedUpdate] =
useLastCheckedUpdateState<string>();
const [latestUpdate, setLatestUpdate] = useLatestUpdateState<null | Record<
string,
any
>>(null);
const [checkState, setCheckState] = useState<null | "LOADING" | "NO_UPDATE">(
null
);
const checkForUpdate = useCallback(async () => {
setCheckState("LOADING");
// https://docs.github.com/en/rest/reference/repos#get-the-latest-release
const endpoint = repository.url
.replace("github.com", "api.github.com/repos")
.replace(/.git$/, "/releases/latest");
try {
const req = await fetch(endpoint, {
headers: {
Accept: "application/vnd.github.v3+json",
},
});
const res = await req.json();
if (res.tag_name > "v" + version) {
setLatestUpdate(res);
setCheckState(null);
} else {
setCheckState("NO_UPDATE");
}
setLastCheckedUpdate(new Date().toISOString());
} catch (e) {
console.error(e);
setLatestUpdate(null);
setCheckState("NO_UPDATE");
}
}, [setLastCheckedUpdate, setLatestUpdate]);
// Check for new updates on page load, if last check was more than 7 days ago
useEffect(() => {
if (!lastCheckedUpdate) checkForUpdate();
else if (differenceInDays(new Date(), new Date(lastCheckedUpdate)) > 7)
checkForUpdate();
}, [lastCheckedUpdate, checkForUpdate]);
// Verify latest update is not installed yet
useEffect(() => {
if (latestUpdate?.tag_name <= "v" + version) setLatestUpdate(null);
}, [latestUpdate, setLatestUpdate]);
const [latestUpdate, checkForUpdates, loading] = useUpdateCheck();
return (
<>
@@ -132,19 +71,29 @@ export default function About() {
<div>
<Grid container spacing={1} alignItems="center" direction="row">
<Grid item xs>
{checkState === "LOADING" ? (
{loading ? (
<Typography display="block">Checking for updates</Typography>
) : latestUpdate === null ? (
) : latestUpdate.rowy === null ? (
<Typography display="block">Up to date</Typography>
) : (
<Typography display="block">
<span
style={{
display: "inline-block",
backgroundColor: "#f00",
borderRadius: "50%",
width: 10,
height: 10,
marginRight: 4,
}}
/>
Update available:{" "}
<Link
href={latestUpdate.html_url}
href={latestUpdate.rowy.html_url}
target="_blank"
rel="noopener noreferrer"
>
{latestUpdate.tag_name}
{latestUpdate.rowy.tag_name}
<InlineOpenInNewIcon />
</Link>
</Typography>
@@ -156,11 +105,8 @@ export default function About() {
</Grid>
<Grid item>
{latestUpdate === null ? (
<LoadingButton
onClick={checkForUpdate}
loading={checkState === "LOADING"}
>
{latestUpdate.rowy === null ? (
<LoadingButton onClick={checkForUpdates} loading={loading}>
Check for updates
</LoadingButton>
) : (

View File

@@ -1,6 +1,4 @@
import { useState, useCallback, useEffect } from "react";
import createPersistedState from "use-persisted-state";
import { differenceInDays } from "date-fns";
import { useState } from "react";
import {
Typography,
@@ -12,19 +10,14 @@ import {
} from "@mui/material";
import LoadingButton from "@mui/lab/LoadingButton";
import InlineOpenInNewIcon from "@src/components/InlineOpenInNewIcon";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { IProjectSettingsChildProps } from "@src/pages/Settings/ProjectSettings";
import { EXTERNAL_LINKS, WIKI_LINKS } from "@src/constants/externalLinks";
import useUpdateCheck from "@src/hooks/useUpdateCheck";
import { name } from "@root/package.json";
import { runRoutes } from "@src/constants/runRoutes";
const useLastCheckedUpdateState = createPersistedState(
"__ROWY__RUN_LAST_CHECKED_UPDATE"
);
export const useLatestUpdateState = createPersistedState(
"__ROWY__RUN_LATEST_UPDATE"
);
export default function RowyRun({
settings,
updateSettings,
@@ -41,7 +34,12 @@ export default function RowyRun({
if (!versionReq.version) throw new Error("No version found");
else {
setVerified(true);
setVersion(versionReq.version);
// If the deployed version is different from the last update check,
// check for updates again to clear update
if (versionReq.version !== latestUpdate.deployedRowyRun)
checkForUpdates();
updateSettings({ rowyRunUrl: inputRowyRunUrl });
}
} catch (e) {
@@ -50,76 +48,7 @@ export default function RowyRun({
}
};
const [lastCheckedUpdate, setLastCheckedUpdate] =
useLastCheckedUpdateState<string>();
const [latestUpdate, setLatestUpdate] = useLatestUpdateState<null | Record<
string,
any
>>(null);
const [checkState, setCheckState] = useState<null | "LOADING" | "NO_UPDATE">(
null
);
const [version, setVersion] = useState("");
useEffect(() => {
fetch(settings.rowyRunUrl + runRoutes.version.path, {
method: runRoutes.version.method,
})
.then((res) => res.json())
.then((data) => setVersion(data.version));
}, [settings.rowyRunUrl]);
const checkForUpdate = useCallback(async () => {
setCheckState("LOADING");
// https://docs.github.com/en/rest/reference/repos#get-the-latest-release
const endpoint =
EXTERNAL_LINKS.rowyRunGitHub.replace(
"github.com",
"api.github.com/repos"
) + "/releases/latest";
try {
const versionReq = await fetch(
settings.rowyRunUrl + runRoutes.version.path,
{ method: runRoutes.version.method }
).then((res) => res.json());
const version = versionReq.version;
setVersion(version);
const req = await fetch(endpoint, {
headers: {
Accept: "application/vnd.github.v3+json",
},
});
const res = await req.json();
if (res.tag_name > "v" + version) {
setLatestUpdate(res);
setCheckState(null);
} else {
setCheckState("NO_UPDATE");
}
setLastCheckedUpdate(new Date().toISOString());
} catch (e) {
console.error(e);
setLatestUpdate(null);
setCheckState("NO_UPDATE");
}
}, [setLastCheckedUpdate, setLatestUpdate, settings.rowyRunUrl]);
// Check for new updates on page load, if last check was more than 7 days ago
useEffect(() => {
if (!lastCheckedUpdate) checkForUpdate();
else if (differenceInDays(new Date(), new Date(lastCheckedUpdate)) > 7)
checkForUpdate();
}, [lastCheckedUpdate, checkForUpdate]);
// Verify latest update is not installed yet
useEffect(() => {
if (version && latestUpdate?.tag_name <= "v" + version)
setLatestUpdate(null);
}, [latestUpdate, setLatestUpdate, version]);
const [latestUpdate, checkForUpdates, loading] = useUpdateCheck();
const deployButton = window.location.hostname.includes(
EXTERNAL_LINKS.rowyAppHostName
@@ -179,35 +108,42 @@ export default function RowyRun({
<div>
<Grid container spacing={1} alignItems="center" direction="row">
<Grid item xs>
{checkState === "LOADING" ? (
{loading ? (
<Typography display="block">Checking for updates</Typography>
) : latestUpdate === null ? (
) : latestUpdate.rowyRun === null ? (
<Typography display="block">Up to date</Typography>
) : (
<Typography display="block">
<span
style={{
display: "inline-block",
backgroundColor: "#f00",
borderRadius: "50%",
width: 10,
height: 10,
marginRight: 4,
}}
/>
Update available:{" "}
<Link
href={latestUpdate.html_url}
href={latestUpdate.rowyRun.html_url}
target="_blank"
rel="noopener noreferrer"
>
{latestUpdate.tag_name}
{latestUpdate.rowyRun.tag_name}
<InlineOpenInNewIcon />
</Link>
</Typography>
)}
<Typography display="block" color="textSecondary">
{name} Run v{version}
{name} Run v{latestUpdate.deployedRowyRun}
</Typography>
</Grid>
<Grid item>
{latestUpdate === null ? (
<LoadingButton
onClick={checkForUpdate}
loading={checkState === "LOADING"}
>
{latestUpdate.rowyRun === null ? (
<LoadingButton onClick={checkForUpdates} loading={loading}>
Check for updates
</LoadingButton>
) : (
@@ -254,11 +190,20 @@ export default function RowyRun({
autoComplete="url"
error={verified === false}
helperText={
verified === true
? `${name} Run is set up correctly`
: verified === false
? `${name} Run is not set up correctly`
: " "
verified === true ? (
<>
<CheckCircleIcon
color="success"
style={{ fontSize: "1rem", verticalAlign: "text-top" }}
/>
&nbsp;
{name} Run is set up correctly
</>
) : verified === false ? (
`${name} Run is not set up correctly`
) : (
" "
)
}
/>
</Grid>

102
src/hooks/useUpdateCheck.ts Normal file
View File

@@ -0,0 +1,102 @@
import { useState, useCallback, useEffect } from "react";
import createPersistedState from "use-persisted-state";
import { differenceInDays } from "date-fns";
import { compare } from "compare-versions";
import { useProjectContext } from "@src/contexts/ProjectContext";
import { repository, version } from "@root/package.json";
import { EXTERNAL_LINKS } from "@src/constants/externalLinks";
import { runRoutes } from "@src/constants/runRoutes";
// https://docs.github.com/en/rest/reference/repos#get-the-latest-release
const UPDATE_ENDPOINTS = {
rowy: repository.url
.replace("github.com", "api.github.com/repos")
.replace(/.git$/, "/releases/latest"),
rowyRun:
EXTERNAL_LINKS.rowyRunGitHub.replace("github.com", "api.github.com/repos") +
"/releases/latest",
};
export const useLatestUpdateState = createPersistedState(
"__ROWY__UPDATE_CHECK"
);
type LatestUpdateState = {
lastChecked: string;
rowy: null | Record<string, any>;
rowyRun: null | Record<string, any>;
deployedRowy: string;
deployedRowyRun: string;
};
/**
* Get the latest version of Rowy and Rowy Run from GitHub releases,
* and the currently deployed versions
* @returns [latestUpdate, checkForUpdates, loading]
*/
export default function useUpdateCheck() {
const { rowyRun } = useProjectContext();
const [loading, setLoading] = useState(false);
// Store latest release from GitHub
const [latestUpdate, setLatestUpdate] =
useLatestUpdateState<LatestUpdateState>({
lastChecked: "",
rowy: null,
rowyRun: null,
deployedRowy: version,
deployedRowyRun: "",
});
// Check for updates using latest releases from GitHub
const checkForUpdates = useCallback(async () => {
if (!rowyRun) return;
setLoading(true);
const newState = {
lastChecked: new Date().toISOString(),
rowy: null,
rowyRun: null,
deployedRowy: version,
deployedRowyRun: "",
};
// Make all requests simultaneously
const [resRowy, resRowyRun, deployedRowyRun] = await Promise.all([
fetch(UPDATE_ENDPOINTS.rowy, {
headers: { Accept: "application/vnd.github.v3+json" },
}).then((r) => r.json()),
fetch(UPDATE_ENDPOINTS.rowyRun, {
headers: { Accept: "application/vnd.github.v3+json" },
}).then((r) => r.json()),
rowyRun({ route: runRoutes.version }),
]);
// Only store the latest release
if (compare(resRowy.tag_name, version, ">")) newState.rowy = resRowy;
if (compare(resRowyRun.tag_name, deployedRowyRun.version, ">"))
newState.rowyRun = resRowyRun;
// Save deployed version
newState.deployedRowyRun = deployedRowyRun.version;
setLatestUpdate(newState);
setLoading(false);
}, [setLoading, setLatestUpdate, rowyRun]);
// Check for new updates on page load if last check was more than 7 days ago
// or if deployed version has changed
useEffect(() => {
if (loading) return;
if (
!latestUpdate.lastChecked ||
differenceInDays(new Date(), new Date(latestUpdate.lastChecked)) > 7 ||
latestUpdate.deployedRowy !== version
)
checkForUpdates();
}, [latestUpdate, loading, checkForUpdates]);
return [latestUpdate, checkForUpdates, loading] as const;
}

View File

@@ -86,7 +86,11 @@ export default function ProjectSettingsPage() {
) : (
<Stack spacing={4}>
{sections.map(({ title, Component, props }, i) => (
<SettingsSection title={title} transitionTimeout={(i + 1) * 100}>
<SettingsSection
key={title}
title={title}
transitionTimeout={(i + 1) * 100}
>
<Component {...(props as any)} />
</SettingsSection>
))}

View File

@@ -65,7 +65,11 @@ export default function UserSettingsPage() {
) : (
<Stack spacing={4}>
{sections.map(({ title, Component, props }, i) => (
<SettingsSection title={title} transitionTimeout={(i + 1) * 100}>
<SettingsSection
key={title}
title={title}
transitionTimeout={(i + 1) * 100}
>
<Component {...(props as any)} />
</SettingsSection>
))}

View File

@@ -5426,6 +5426,11 @@ compare-versions@^3.6.0:
resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-3.6.0.tgz#1a5689913685e5a87637b8d3ffca75514ec41d62"
integrity sha512-W6Af2Iw1z4CB7q4uU4hv646dW9GQuBM+YpC0UvUCWSD8w90SJjp+ujJuXaEMtAXBtSqGfMPuFOVn4/+FlaqfBA==
compare-versions@^4.1.1:
version "4.1.1"
resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-4.1.1.tgz#d881fc9f47d6eb2b8f63109dc5e82dae39c3680c"
integrity sha512-jHQA7zMUpbO+FhPz/kADChZVSk3edtD7c3WkEAjleBtwgAl0ji6wGrYxryaBhViGgq0A+Pb6JPhjhg9jpth4mQ==
component-emitter@^1.2.1:
version "1.3.0"
resolved "https://registry.yarnpkg.com/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0"