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 00771d328..abeba47c4 100644 --- a/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx +++ b/apps/dokploy/components/dashboard/settings/web-server/update-webserver.tsx @@ -1,4 +1,11 @@ -import { HardDriveDownload, Loader2 } from "lucide-react"; +import { + AlertTriangle, + CheckCircle2, + HardDriveDownload, + Loader2, + RefreshCw, + XCircle, +} from "lucide-react"; import { useState } from "react"; import { toast } from "sonner"; import { @@ -15,11 +22,70 @@ import { import { Button } from "@/components/ui/button"; import { api } from "@/utils/api"; +type ServiceStatus = { + status: "healthy" | "unhealthy"; + message?: string; +}; + +type HealthResult = { + postgres: ServiceStatus; + redis: ServiceStatus; + traefik: ServiceStatus; +}; + +type ModalState = "idle" | "checking" | "results" | "updating"; + +const ServiceStatusItem = ({ + name, + service, +}: { + name: string; + service: ServiceStatus; +}) => ( +
+ {service.status === "healthy" ? ( + + ) : ( + + )} + {name} + {service.status === "unhealthy" && service.message && ( + — {service.message} + )} +
+); + export const UpdateWebServer = () => { - const [updating, setUpdating] = useState(false); + const [modalState, setModalState] = useState("idle"); const [open, setOpen] = useState(false); + const [healthResult, setHealthResult] = useState(null); const { mutateAsync: updateServer } = api.settings.updateServer.useMutation(); + const { refetch: checkHealth } = + api.settings.checkInfrastructureHealth.useQuery(undefined, { + enabled: false, + }); + + const handleVerify = async () => { + setModalState("checking"); + setHealthResult(null); + + try { + const result = await checkHealth(); + if (result.data) { + setHealthResult(result.data); + } + } catch { + // checkHealth failed entirely + } + setModalState("results"); + }; + + const allHealthy = + healthResult && + healthResult.postgres.status === "healthy" && + healthResult.redis.status === "healthy" && + healthResult.traefik.status === "healthy"; const checkIsUpdateFinished = async () => { try { @@ -33,28 +99,24 @@ export const UpdateWebServer = () => { ); setTimeout(() => { - // Allow seeing the toast before reloading window.location.reload(); }, 2000); } catch { - // Delay each request await new Promise((resolve) => setTimeout(resolve, 2000)); - // Keep running until it returns 200 void checkIsUpdateFinished(); } }; const handleConfirm = async () => { try { - setUpdating(true); + setModalState("updating"); await updateServer(); - // Give some time for docker service restart before starting to check status await new Promise((resolve) => setTimeout(resolve, 8000)); await checkIsUpdateFinished(); } catch (error) { - setUpdating(false); + setModalState("results"); console.error("Error updating server:", error); toast.error( "An error occurred while updating the server, please try again.", @@ -62,6 +124,14 @@ export const UpdateWebServer = () => { } }; + const handleClose = () => { + if (modalState !== "updating") { + setOpen(false); + setModalState("idle"); + setHealthResult(null); + } + }; + return ( @@ -81,36 +151,111 @@ export const UpdateWebServer = () => { - {updating - ? "Server update in progress" - : "Are you absolutely sure?"} + {modalState === "idle" && "Are you absolutely sure?"} + {modalState === "checking" && "Verifying Services..."} + {modalState === "results" && + (allHealthy ? "Ready to Update" : "Service Issues Detected")} + {modalState === "updating" && "Server update in progress"} - - {updating ? ( - - - The server is being updated, please wait... - - ) : ( - <> - This action cannot be undone. This will update the web server to - the new version. You will not be able to use the panel during - the update process. The page will be reloaded once the update is - finished. - - )} + +
+ {modalState === "idle" && ( + + This will update the web server to the new version. You will + not be able to use the panel during the update process. The + page will be reloaded once the update is finished. +
+
+ We recommend verifying that all services are running before + updating. +
+ )} + + {modalState === "checking" && ( + + + Checking PostgreSQL, Redis and Traefik... + + )} + + {modalState === "results" && healthResult && ( +
+
+ + + +
+ + {!allHealthy && ( +
+ + + Some services are not healthy. You can still proceed + with the update. + +
+ )} + + {allHealthy && ( + + All services are running. You can proceed with the update. + + )} +
+ )} + + {modalState === "results" && !healthResult && ( +
+ + + Could not verify services. You can still proceed with the + update. + +
+ )} + + {modalState === "updating" && ( + + + The server is being updated, please wait... + + )} +
- {!updating && ( + {modalState === "idle" && ( - setOpen(false)}> - Cancel - + Cancel + Confirm )} + {modalState === "results" && ( + + Cancel + + + {allHealthy ? "Confirm" : "Confirm Anyway"} + + + )}
); diff --git a/apps/dokploy/server/api/routers/settings.ts b/apps/dokploy/server/api/routers/settings.ts index e52842f73..4b12abc4c 100644 --- a/apps/dokploy/server/api/routers/settings.ts +++ b/apps/dokploy/server/api/routers/settings.ts @@ -2,6 +2,9 @@ import { CLEANUP_CRON_JOB, checkGPUStatus, checkPortInUse, + checkPostgresHealth, + checkRedisHealth, + checkTraefikHealth, cleanupAll, cleanupAllBackground, cleanupBuilders, @@ -44,8 +47,8 @@ import { writeTraefikConfigInPath, writeTraefikSetup, } from "@dokploy/server"; -import { checkPermission } from "@dokploy/server/services/permission"; import { db } from "@dokploy/server/db"; +import { checkPermission } from "@dokploy/server/services/permission"; import { generateOpenApiDocument } from "@dokploy/trpc-openapi"; import { TRPCError } from "@trpc/server"; import { eq, sql } from "drizzle-orm"; @@ -864,6 +867,23 @@ export const settingsRouter = createTRPCRouter({ throw error; } }), + checkInfrastructureHealth: adminProcedure.query(async () => { + if (IS_CLOUD) { + return { + postgres: { status: "healthy" as const }, + redis: { status: "healthy" as const }, + traefik: { status: "healthy" as const }, + }; + } + + const [postgres, redis, traefik] = await Promise.all([ + checkPostgresHealth(), + checkRedisHealth(), + checkTraefikHealth(), + ]); + + return { postgres, redis, traefik }; + }), setupGPU: adminProcedure .input( z.object({ diff --git a/packages/server/src/utils/docker/utils.ts b/packages/server/src/utils/docker/utils.ts index 144df2c14..858d0fd35 100644 --- a/packages/server/src/utils/docker/utils.ts +++ b/packages/server/src/utils/docker/utils.ts @@ -741,3 +741,177 @@ export const getComposeContainer = async ( throw error; } }; + +type ServiceHealthStatus = { + status: "healthy" | "unhealthy"; + message?: string; +}; + +const checkSwarmServiceRunning = async ( + serviceName: string, +): Promise => { + try { + const service = docker.getService(serviceName); + const info = await service.inspect(); + const replicas = info.Spec?.Mode?.Replicated?.Replicas ?? 0; + if (replicas === 0) { + return { + status: "unhealthy", + message: "Service has 0 replicas configured", + }; + } + + // Check that at least one task is actually running + const tasks = await docker.listTasks({ + filters: JSON.stringify({ + service: [serviceName], + "desired-state": ["running"], + }), + }); + + const runningTask = tasks.find((t) => t.Status?.State === "running"); + + if (!runningTask) { + const latestTask = tasks[0]; + const taskState = latestTask?.Status?.State ?? "unknown"; + return { + status: "unhealthy", + message: `No running tasks (current state: ${taskState})`, + }; + } + + return { status: "healthy" }; + } catch (error) { + return { + status: "unhealthy", + message: error instanceof Error ? error.message : "Service not found", + }; + } +}; + +const getSwarmServiceContainerId = async ( + serviceName: string, +): Promise => { + try { + const tasks = await docker.listTasks({ + filters: JSON.stringify({ + service: [serviceName], + "desired-state": ["running"], + }), + }); + + const runningTask = tasks.find((t) => t.Status?.State === "running"); + + return runningTask?.Status?.ContainerStatus?.ContainerID ?? null; + } catch { + return null; + } +}; + +export const checkPostgresHealth = async (): Promise => { + const serviceCheck = await checkSwarmServiceRunning("dokploy-postgres"); + if (serviceCheck.status === "unhealthy") { + return serviceCheck; + } + + // Verify PostgreSQL actually accepts connections + const containerId = await getSwarmServiceContainerId("dokploy-postgres"); + if (!containerId) { + return { status: "unhealthy", message: "Could not find running container" }; + } + + try { + const exec = await docker.getContainer(containerId).exec({ + Cmd: ["pg_isready", "-U", "dokploy"], + AttachStdout: true, + AttachStderr: true, + }); + const stream = await exec.start({}); + + const output = await new Promise((resolve) => { + let data = ""; + stream.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); + stream.on("end", () => resolve(data)); + }); + + const inspectResult = await exec.inspect(); + if (inspectResult.ExitCode !== 0) { + return { + status: "unhealthy", + message: `PostgreSQL not ready: ${output.trim()}`, + }; + } + + return { status: "healthy" }; + } catch (error) { + return { + status: "unhealthy", + message: + error instanceof Error ? error.message : "Failed to check PostgreSQL", + }; + } +}; + +export const checkRedisHealth = async (): Promise => { + const serviceCheck = await checkSwarmServiceRunning("dokploy-redis"); + if (serviceCheck.status === "unhealthy") { + return serviceCheck; + } + + // Verify Redis actually responds to PING + const containerId = await getSwarmServiceContainerId("dokploy-redis"); + if (!containerId) { + return { status: "unhealthy", message: "Could not find running container" }; + } + + try { + const exec = await docker.getContainer(containerId).exec({ + Cmd: ["redis-cli", "ping"], + AttachStdout: true, + AttachStderr: true, + }); + const stream = await exec.start({}); + + const output = await new Promise((resolve) => { + let data = ""; + stream.on("data", (chunk: Buffer) => { + data += chunk.toString(); + }); + stream.on("end", () => resolve(data)); + }); + + if (!output.includes("PONG")) { + return { + status: "unhealthy", + message: `Redis did not respond with PONG: ${output.trim()}`, + }; + } + + return { status: "healthy" }; + } catch (error) { + return { + status: "unhealthy", + message: error instanceof Error ? error.message : "Failed to check Redis", + }; + } +}; + +export const checkTraefikHealth = async (): Promise => { + // Traefik can run as a standalone container or a swarm service + try { + const container = docker.getContainer("dokploy-traefik"); + const info = await container.inspect(); + if (!info.State.Running) { + return { + status: "unhealthy", + message: "Container is not running", + }; + } + return { status: "healthy" }; + } catch { + // Not a standalone container, check as swarm service + return checkSwarmServiceRunning("dokploy-traefik"); + } +};