mirror of
https://github.com/Dokploy/dokploy
synced 2025-06-26 18:27:59 +00:00
Merge pull request #951 from szwabodev/checkUpdatesTweaks
feat: automatic check for updates
This commit is contained in:
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user