diff --git a/apps/dokploy/components/dashboard/settings/web-server/toggle-auto-check-updates.tsx b/apps/dokploy/components/dashboard/settings/web-server/toggle-auto-check-updates.tsx new file mode 100644 index 00000000..5c07d5df --- /dev/null +++ b/apps/dokploy/components/dashboard/settings/web-server/toggle-auto-check-updates.tsx @@ -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( + localStorage.getItem("enableAutoCheckUpdates") === "true", + ); + + const handleToggle = (checked: boolean) => { + setEnabled(checked); + localStorage.setItem("enableAutoCheckUpdates", String(checked)); + }; + + return ( +
+ + +
+ ); +}; diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx index 48a61c7a..1b8798e4 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-server.tsx @@ -14,15 +14,34 @@ import Link from "next/link"; import { useState } from "react"; import { toast } from "sonner"; import { UpdateWebServer } from "./update-webserver"; +import { ToggleAutoCheckUpdates } from "./toggle-auto-check-updates"; export const UpdateServer = () => { const [isUpdateAvailable, setIsUpdateAvailable] = useState( null, ); - const { mutateAsync: checkAndUpdateImage, isLoading } = - api.settings.checkAndUpdateImage.useMutation(); + const { mutateAsync: getUpdateData, isLoading } = + api.settings.getUpdateData.useMutation(); 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 ( @@ -61,6 +80,7 @@ export const UpdateServer = () => {
+ {isUpdateAvailable === false && (
@@ -74,20 +94,10 @@ export const UpdateServer = () => { ) : ( )}
diff --git a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx index 47d38310..9b3c89f6 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx @@ -13,22 +13,49 @@ import { Button } from "@/components/ui/button"; import { api } from "@/utils/api"; import { toast } from "sonner"; -export const UpdateWebServer = () => { +interface Props { + isNavbar?: boolean; +} + +export const UpdateWebServer = ({ isNavbar }: Props) => { const { mutateAsync: updateServer, isLoading } = 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 ( @@ -36,19 +63,12 @@ export const UpdateWebServer = () => { Are you absolutely sure? 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. Cancel - { - await updateServer(); - toast.success("Please reload the browser to see the changes"); - }} - > - Confirm - + Confirm diff --git a/apps/dokploy/components/layouts/navbar.tsx b/apps/dokploy/components/layouts/navbar.tsx index cead4683..a8eb4574 100644 --- a/apps/dokploy/components/layouts/navbar.tsx +++ b/apps/dokploy/components/layouts/navbar.tsx @@ -15,8 +15,13 @@ import { useRouter } from "next/router"; import { Logo } from "../shared/logo"; import { Avatar, AvatarFallback, AvatarImage } from "../ui/avatar"; 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 = () => { + const [isUpdateAvailable, setIsUpdateAvailable] = useState(false); const router = useRouter(); const { data } = api.auth.get.useQuery(); const { data: isCloud } = api.settings.isCloud.useQuery(); @@ -29,6 +34,59 @@ export const Navbar = () => { }, ); const { mutateAsync } = api.auth.logout.useMutation(); + const { mutateAsync: getUpdateData } = + api.settings.getUpdateData.useMutation(); + + const checkUpdatesIntervalRef = useRef(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 (
+ {isUpdateAvailable && ( +
+ +
+ )} { + getUpdateData: adminProcedure.mutation(async () => { if (IS_CLOUD) { - return true; + return DEFAULT_UPDATE_DATA; } - return await pullLatestRelease(); + + return await getUpdateData(); }), updateServer: adminProcedure.mutation(async () => { if (IS_CLOUD) { return true; } + + await pullLatestRelease(); + await spawnAsync("docker", [ "service", "update", @@ -361,6 +366,7 @@ export const settingsRouter = createTRPCRouter({ getDokployImage(), "dokploy", ]); + return true; }), diff --git a/packages/server/src/services/settings.ts b/packages/server/src/services/settings.ts index 8261843a..0ab9744e 100644 --- a/packages/server/src/services/settings.ts +++ b/packages/server/src/services/settings.ts @@ -1,39 +1,100 @@ import { readdirSync } from "node:fs"; import { join } from "node:path"; import { docker } from "@dokploy/server/constants"; -import { getServiceContainer } from "@dokploy/server/utils/docker/utils"; -import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync"; +import { + execAsync, + execAsyncRemote, +} from "@dokploy/server/utils/process/execAsync"; // import packageInfo from "../../../package.json"; -const updateIsAvailable = async () => { - try { - const service = await getServiceContainer("dokploy"); +export interface IUpdateData { + latestVersion: string | null; + updateAvailable: boolean; +} - const localImage = await docker.getImage(getDokployImage()).inspect(); - return localImage.Id !== service?.ImageID; - } catch (error) { - return false; - } +export const DEFAULT_UPDATE_DATA: IUpdateData = { + latestVersion: null, + updateAvailable: false, +}; + +/** Returns current Dokploy docker image tag or `latest` by default. */ +export const getDokployImageTag = () => { + return process.env.RELEASE_TAG || "latest"; }; export const getDokployImage = () => { - return `dokploy/dokploy:${process.env.RELEASE_TAG || "latest"}`; + return `dokploy/dokploy:${getDokployImageTag()}`; }; export const pullLatestRelease = async () => { - try { - const stream = await docker.pull(getDokployImage(), {}); - await new Promise((resolve, reject) => { - docker.modem.followProgress(stream, (err, res) => - err ? reject(err) : resolve(res), - ); - }); - const newUpdateIsAvailable = await updateIsAvailable(); - return newUpdateIsAvailable; - } catch (error) {} - - return false; + const stream = await docker.pull(getDokployImage()); + await new Promise((resolve, reject) => { + docker.modem.followProgress(stream, (err, res) => + err ? reject(err) : resolve(res), + ); + }); }; + +/** 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 => { + 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 = () => { // return packageInfo.version; };