Merge pull request #951 from szwabodev/checkUpdatesTweaks

feat: automatic check for updates
This commit is contained in:
Mauricio Siu
2024-12-21 12:45:39 -06:00
committed by GitHub
6 changed files with 244 additions and 57 deletions

View File

@@ -0,0 +1,27 @@
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { useState } from "react";
export const ToggleAutoCheckUpdates = () => {
const [enabled, setEnabled] = useState<boolean>(
localStorage.getItem("enableAutoCheckUpdates") === "true",
);
const handleToggle = (checked: boolean) => {
setEnabled(checked);
localStorage.setItem("enableAutoCheckUpdates", String(checked));
};
return (
<div className="flex items-center gap-4">
<Switch
checked={enabled}
onCheckedChange={handleToggle}
id="autoCheckUpdatesToggle"
/>
<Label className="text-primary" htmlFor="autoCheckUpdatesToggle">
Automatically check for new updates
</Label>
</div>
);
};

View File

@@ -14,15 +14,34 @@ import Link from "next/link";
import { useState } from "react"; import { useState } from "react";
import { toast } from "sonner"; import { toast } from "sonner";
import { UpdateWebServer } from "./update-webserver"; import { UpdateWebServer } from "./update-webserver";
import { ToggleAutoCheckUpdates } from "./toggle-auto-check-updates";
export const UpdateServer = () => { export const UpdateServer = () => {
const [isUpdateAvailable, setIsUpdateAvailable] = useState<null | boolean>( const [isUpdateAvailable, setIsUpdateAvailable] = useState<null | boolean>(
null, null,
); );
const { mutateAsync: checkAndUpdateImage, isLoading } = const { mutateAsync: getUpdateData, isLoading } =
api.settings.checkAndUpdateImage.useMutation(); api.settings.getUpdateData.useMutation();
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const handleCheckUpdates = async () => {
try {
const { updateAvailable, latestVersion } = await getUpdateData();
setIsUpdateAvailable(updateAvailable);
if (updateAvailable) {
toast.success(`${latestVersion} update is available!`);
} else {
toast.info("No updates available");
}
} catch (error) {
console.error("Error checking for updates:", error);
setIsUpdateAvailable(false);
toast.error(
"An error occurred while checking for updates, please try again.",
);
}
};
return ( return (
<Dialog open={isOpen} onOpenChange={setIsOpen}> <Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -61,6 +80,7 @@ export const UpdateServer = () => {
</AlertBlock> </AlertBlock>
<div className="w-full flex flex-col gap-4"> <div className="w-full flex flex-col gap-4">
<ToggleAutoCheckUpdates />
{isUpdateAvailable === false && ( {isUpdateAvailable === false && (
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<RefreshCcw className="size-6 self-center text-muted-foreground" /> <RefreshCcw className="size-6 self-center text-muted-foreground" />
@@ -74,20 +94,10 @@ export const UpdateServer = () => {
) : ( ) : (
<Button <Button
className="w-full" className="w-full"
onClick={async () => { onClick={handleCheckUpdates}
await checkAndUpdateImage()
.then(async (e) => {
setIsUpdateAvailable(e);
})
.catch(() => {
setIsUpdateAvailable(false);
toast.error("Error to check updates");
});
toast.success("Check updates");
}}
isLoading={isLoading} isLoading={isLoading}
> >
Check Updates {isLoading ? "Checking for updates..." : "Check for updates"}
</Button> </Button>
)} )}
</div> </div>

View File

@@ -13,22 +13,49 @@ import { Button } from "@/components/ui/button";
import { api } from "@/utils/api"; import { api } from "@/utils/api";
import { toast } from "sonner"; import { toast } from "sonner";
export const UpdateWebServer = () => { interface Props {
isNavbar?: boolean;
}
export const UpdateWebServer = ({ isNavbar }: Props) => {
const { mutateAsync: updateServer, isLoading } = const { mutateAsync: updateServer, isLoading } =
api.settings.updateServer.useMutation(); api.settings.updateServer.useMutation();
const buttonLabel = isNavbar ? "Update available" : "Update server";
const handleConfirm = async () => {
try {
await updateServer();
toast.success(
"The server has been updated. The page will be reloaded to reflect the changes...",
);
setTimeout(() => {
// Allow seeing the toast before reloading
window.location.reload();
}, 2000);
} catch (error) {
console.error("Error updating server:", error);
toast.error(
"An error occurred while updating the server, please try again.",
);
}
};
return ( return (
<AlertDialog> <AlertDialog>
<AlertDialogTrigger asChild> <AlertDialogTrigger asChild>
<Button <Button
className="relative w-full" className="relative w-full"
variant="secondary" variant={isNavbar ? "outline" : "secondary"}
isLoading={isLoading} isLoading={isLoading}
> >
<span className="absolute -right-1 -top-2 flex h-3 w-3"> {!isLoading && (
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" /> <span className="absolute -right-1 -top-2 flex h-3 w-3">
<span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" /> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-green-400 opacity-75" />
</span> <span className="relative inline-flex rounded-full h-3 w-3 bg-green-500" />
Update Server </span>
)}
{isLoading ? "Updating..." : buttonLabel}
</Button> </Button>
</AlertDialogTrigger> </AlertDialogTrigger>
<AlertDialogContent> <AlertDialogContent>
@@ -36,19 +63,12 @@ export const UpdateWebServer = () => {
<AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle> <AlertDialogTitle>Are you absolutely sure?</AlertDialogTitle>
<AlertDialogDescription> <AlertDialogDescription>
This action cannot be undone. This will update the web server to the This action cannot be undone. This will update the web server to the
new version. new version. The page will be reloaded once the update is finished.
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter> <AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel> <AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction <AlertDialogAction onClick={handleConfirm}>Confirm</AlertDialogAction>
onClick={async () => {
await updateServer();
toast.success("Please reload the browser to see the changes");
}}
>
Confirm
</AlertDialogAction>
</AlertDialogFooter> </AlertDialogFooter>
</AlertDialogContent> </AlertDialogContent>
</AlertDialog> </AlertDialog>

View File

@@ -15,8 +15,13 @@ import { useRouter } from "next/router";
import { Logo } from "../shared/logo"; import { Logo } from "../shared/logo";
import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar";
import { buttonVariants } from "../ui/button"; import { buttonVariants } from "../ui/button";
import { useEffect, useRef, useState } from "react";
import { UpdateWebServer } from "../dashboard/settings/web-server/update-webserver";
const AUTO_CHECK_UPDATES_INTERVAL_MINUTES = 5;
export const Navbar = () => { export const Navbar = () => {
const [isUpdateAvailable, setIsUpdateAvailable] = useState<boolean>(false);
const router = useRouter(); const router = useRouter();
const { data } = api.auth.get.useQuery(); const { data } = api.auth.get.useQuery();
const { data: isCloud } = api.settings.isCloud.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery();
@@ -29,6 +34,59 @@ export const Navbar = () => {
}, },
); );
const { mutateAsync } = api.auth.logout.useMutation(); const { mutateAsync } = api.auth.logout.useMutation();
const { mutateAsync: getUpdateData } =
api.settings.getUpdateData.useMutation();
const checkUpdatesIntervalRef = useRef<null | NodeJS.Timeout>(null);
useEffect(() => {
// Handling of automatic check for server updates
if (isCloud) {
return;
}
if (!localStorage.getItem("enableAutoCheckUpdates")) {
// Enable auto update checking by default if user didn't change it
localStorage.setItem("enableAutoCheckUpdates", "true");
}
const clearUpdatesInterval = () => {
if (checkUpdatesIntervalRef.current) {
clearInterval(checkUpdatesIntervalRef.current);
}
};
const checkUpdates = async () => {
try {
if (localStorage.getItem("enableAutoCheckUpdates") !== "true") {
return;
}
const { updateAvailable } = await getUpdateData();
if (updateAvailable) {
// Stop interval when update is available
clearUpdatesInterval();
setIsUpdateAvailable(true);
}
} catch (error) {
console.error("Error auto-checking for updates:", error);
}
};
checkUpdatesIntervalRef.current = setInterval(
checkUpdates,
AUTO_CHECK_UPDATES_INTERVAL_MINUTES * 60000,
);
// Also check for updates on initial page load
checkUpdates();
return () => {
clearUpdatesInterval();
};
}, []);
return ( return (
<nav className="border-divider sticky inset-x-0 top-0 z-40 flex h-auto w-full items-center justify-center border-b bg-background/70 backdrop-blur-lg backdrop-saturate-150 data-[menu-open=true]:border-none data-[menu-open=true]:backdrop-blur-xl"> <nav className="border-divider sticky inset-x-0 top-0 z-40 flex h-auto w-full items-center justify-center border-b bg-background/70 backdrop-blur-lg backdrop-saturate-150 data-[menu-open=true]:border-none data-[menu-open=true]:backdrop-blur-xl">
<header className="relative z-40 flex w-full max-w-8xl flex-row flex-nowrap items-center justify-between gap-4 px-4 sm:px-6 h-16"> <header className="relative z-40 flex w-full max-w-8xl flex-row flex-nowrap items-center justify-between gap-4 px-4 sm:px-6 h-16">
@@ -43,6 +101,11 @@ export const Navbar = () => {
</span> </span>
</Link> </Link>
</div> </div>
{isUpdateAvailable && (
<div>
<UpdateWebServer isNavbar />
</div>
)}
<Link <Link
className={buttonVariants({ className={buttonVariants({
variant: "outline", variant: "outline",

View File

@@ -45,12 +45,14 @@ import {
stopService, stopService,
stopServiceRemote, stopServiceRemote,
updateAdmin, updateAdmin,
getUpdateData,
updateLetsEncryptEmail, updateLetsEncryptEmail,
updateServerById, updateServerById,
updateServerTraefik, updateServerTraefik,
writeConfig, writeConfig,
writeMainConfig, writeMainConfig,
writeTraefikConfigInPath, writeTraefikConfigInPath,
DEFAULT_UPDATE_DATA,
} from "@dokploy/server"; } from "@dokploy/server";
import { checkGPUStatus, setupGPUSupport } from "@dokploy/server"; import { checkGPUStatus, setupGPUSupport } from "@dokploy/server";
import { generateOpenApiDocument } from "@dokploy/trpc-openapi"; import { generateOpenApiDocument } from "@dokploy/trpc-openapi";
@@ -342,17 +344,20 @@ export const settingsRouter = createTRPCRouter({
writeConfig("middlewares", input.traefikConfig); writeConfig("middlewares", input.traefikConfig);
return true; return true;
}), }),
getUpdateData: adminProcedure.mutation(async () => {
checkAndUpdateImage: adminProcedure.mutation(async () => {
if (IS_CLOUD) { if (IS_CLOUD) {
return true; return DEFAULT_UPDATE_DATA;
} }
return await pullLatestRelease();
return await getUpdateData();
}), }),
updateServer: adminProcedure.mutation(async () => { updateServer: adminProcedure.mutation(async () => {
if (IS_CLOUD) { if (IS_CLOUD) {
return true; return true;
} }
await pullLatestRelease();
await spawnAsync("docker", [ await spawnAsync("docker", [
"service", "service",
"update", "update",
@@ -361,6 +366,7 @@ export const settingsRouter = createTRPCRouter({
getDokployImage(), getDokployImage(),
"dokploy", "dokploy",
]); ]);
return true; return true;
}), }),

View File

@@ -1,39 +1,100 @@
import { readdirSync } from "node:fs"; import { readdirSync } from "node:fs";
import { join } from "node:path"; import { join } from "node:path";
import { docker } from "@dokploy/server/constants"; import { docker } from "@dokploy/server/constants";
import { getServiceContainer } from "@dokploy/server/utils/docker/utils"; import {
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; execAsync,
execAsyncRemote,
} from "@dokploy/server/utils/process/execAsync";
// import packageInfo from "../../../package.json"; // import packageInfo from "../../../package.json";
const updateIsAvailable = async () => { export interface IUpdateData {
try { latestVersion: string | null;
const service = await getServiceContainer("dokploy"); updateAvailable: boolean;
}
const localImage = await docker.getImage(getDokployImage()).inspect(); export const DEFAULT_UPDATE_DATA: IUpdateData = {
return localImage.Id !== service?.ImageID; latestVersion: null,
} catch (error) { updateAvailable: false,
return false; };
}
/** Returns current Dokploy docker image tag or `latest` by default. */
export const getDokployImageTag = () => {
return process.env.RELEASE_TAG || "latest";
}; };
export const getDokployImage = () => { export const getDokployImage = () => {
return `dokploy/dokploy:${process.env.RELEASE_TAG || "latest"}`; return `dokploy/dokploy:${getDokployImageTag()}`;
}; };
export const pullLatestRelease = async () => { export const pullLatestRelease = async () => {
try { const stream = await docker.pull(getDokployImage());
const stream = await docker.pull(getDokployImage(), {}); await new Promise((resolve, reject) => {
await new Promise((resolve, reject) => { docker.modem.followProgress(stream, (err, res) =>
docker.modem.followProgress(stream, (err, res) => err ? reject(err) : resolve(res),
err ? reject(err) : resolve(res), );
); });
});
const newUpdateIsAvailable = await updateIsAvailable();
return newUpdateIsAvailable;
} catch (error) {}
return false;
}; };
/** Returns Dokploy docker service image digest */
export const getServiceImageDigest = async () => {
const { stdout } = await execAsync(
"docker service inspect dokploy --format '{{.Spec.TaskTemplate.ContainerSpec.Image}}'",
);
const currentDigest = stdout.trim().split("@")[1];
if (!currentDigest) {
throw new Error("Could not get current service image digest");
}
return currentDigest;
};
/** Returns latest version number and information whether server update is available by comparing current image's digest against digest for provided image tag via Docker hub API. */
export const getUpdateData = async (): Promise<IUpdateData> => {
let currentDigest: string;
try {
currentDigest = await getServiceImageDigest();
} catch {
// Docker service might not exist locally
// You can run the # Installation command for docker service create mentioned in the below docs to test it locally:
// https://docs.dokploy.com/docs/core/manual-installation
return DEFAULT_UPDATE_DATA;
}
const url = "https://hub.docker.com/v2/repositories/dokploy/dokploy/tags";
const response = await fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
});
const data = (await response.json()) as {
results: [{ digest: string; name: string }];
};
const { results } = data;
const latestTagDigest = results.find(
(t) => t.name === getDokployImageTag(),
)?.digest;
if (!latestTagDigest) {
return DEFAULT_UPDATE_DATA;
}
const versionedTag = results.find(
(t) => t.digest === latestTagDigest && t.name.startsWith("v"),
);
if (!versionedTag) {
return DEFAULT_UPDATE_DATA;
}
const { name: latestVersion, digest } = versionedTag;
const updateAvailable = digest !== currentDigest;
return { latestVersion, updateAvailable };
};
export const getDokployVersion = () => { export const getDokployVersion = () => {
// return packageInfo.version; // return packageInfo.version;
}; };