diff --git a/backend/agent-socket-handlers/docker-socket-handler.ts b/backend/agent-socket-handlers/docker-socket-handler.ts index 81746019..230185e8 100644 --- a/backend/agent-socket-handlers/docker-socket-handler.ts +++ b/backend/agent-socket-handlers/docker-socket-handler.ts @@ -3,6 +3,7 @@ import { DockgeServer } from "../dockge-server"; import { callbackError, callbackResult, checkLogin, DockgeSocket, ValidationError } from "../util-server"; import { Stack } from "../stack"; import { AgentSocket } from "../../common/agent-socket"; +import childProcessAsync from "promisify-child-process"; export class DockerSocketHandler extends AgentSocketHandler { create(socket : DockgeSocket, server : DockgeServer, agentSocket : AgentSocket) { @@ -251,6 +252,107 @@ export class DockerSocketHandler extends AgentSocketHandler { callbackError(e, callback); } }); + + // getDockerImageList + agentSocket.on("getDockerImageList", async (callback) => { + try { + checkLogin(socket); + const imageList = await this.getDockerImageList(); + callbackResult({ + ok: true, + imageList, + }, callback); + } catch (e) { + callbackError(e, callback); + } + }); + + // deleteDockerImage + agentSocket.on("deleteDockerImage", async (imageId : unknown, callback) => { + try { + checkLogin(socket); + if (typeof(imageId) !== "string") { + throw new ValidationError("Image ID must be a string"); + } + await this.deleteDockerImage(imageId); + callbackResult({ + ok: true, + msg: "Image Deleted", + msgi18n: true, + }, callback); + } catch (e) { + callbackError(e, callback); + } + }); + + // getDockerDiskUsage + agentSocket.on("getDockerDiskUsage", async (callback) => { + try { + checkLogin(socket); + const diskUsage = await this.getDockerDiskUsage(); + callbackResult({ + ok: true, + diskUsage, + }, callback); + } catch (e) { + callbackError(e, callback); + } + }); + + // pullDockerImage + agentSocket.on("pullDockerImage", async (imageName : unknown, callback) => { + try { + checkLogin(socket); + if (typeof(imageName) !== "string") { + throw new ValidationError("Image name must be a string"); + } + await this.pullDockerImage(imageName); + callbackResult({ + ok: true, + msg: "Image Pulled", + msgi18n: true, + }, callback); + } catch (e) { + callbackError(e, callback); + } + }); + + // buildDockerImage + agentSocket.on("buildDockerImage", async (imageName : unknown, dockerfileContent : unknown, callback) => { + try { + checkLogin(socket); + if (typeof(imageName) !== "string") { + throw new ValidationError("Image name must be a string"); + } + if (typeof(dockerfileContent) !== "string") { + throw new ValidationError("Dockerfile content must be a string"); + } + await this.buildDockerImage(imageName, dockerfileContent); + callbackResult({ + ok: true, + msg: "Image Built", + msgi18n: true, + }, callback); + } catch (e) { + callbackError(e, callback); + } + }); + + // pruneDockerImages + agentSocket.on("pruneDockerImages", async (callback) => { + try { + checkLogin(socket); + const result = await this.pruneDockerImages(); + callbackResult({ + ok: true, + msg: "Images Pruned", + msgi18n: true, + result, + }, callback); + } catch (e) { + callbackError(e, callback); + } + }); } async saveStack(server : DockgeServer, name : unknown, composeYAML : unknown, composeENV : unknown, isAdd : unknown) : Promise { @@ -273,5 +375,151 @@ export class DockerSocketHandler extends AgentSocketHandler { return stack; } + /** + * Get the list of Docker images + * @returns List of Docker images with their details + */ + async getDockerImageList() { + const res = await childProcessAsync.spawn("docker", [ "images", "--format", "json" ], { + encoding: "utf-8", + }); + + if (!res.stdout) { + return []; + } + + const output = res.stdout.toString().trim(); + if (!output) { + return []; + } + + const lines = output.split("\n"); + const imageList = lines.map((line : string) => { + try { + return JSON.parse(line); + } catch (e) { + return null; + } + }).filter((img : unknown) => img !== null); + + return imageList; + } + + /** + * Delete a Docker image by ID or name + * @param imageId - The image ID or name to delete + * @throws Error with Docker error message if deletion fails + */ + async deleteDockerImage(imageId : string) { + try { + await childProcessAsync.spawn("docker", [ "rmi", imageId ], { + encoding: "utf-8", + }); + } catch (error : any) { + // Extract meaningful error message from Docker + const stderr = error.stderr?.toString() || ""; + const stdout = error.stdout?.toString() || ""; + const errorMessage = stderr || stdout || error.message || "Failed to delete image"; + + // Throw error with the actual Docker message + throw new Error(errorMessage); + } + } + + /** + * Get Docker disk usage information + * @returns Docker disk usage statistics + */ + async getDockerDiskUsage() { + const res = await childProcessAsync.spawn("docker", [ "system", "df", "--format", "json" ], { + encoding: "utf-8", + }); + + if (!res.stdout) { + return {}; + } + + const output = res.stdout.toString().trim(); + if (!output) { + return {}; + } + + return JSON.parse(output); + } + + /** + * Pull a Docker image from registry + * @param imageName - The image name to pull (e.g., "nginx:latest") + * @throws Error with Docker error message if pull fails + */ + async pullDockerImage(imageName : string) { + try { + await childProcessAsync.spawn("docker", [ "pull", imageName ], { + encoding: "utf-8", + }); + } catch (error : any) { + const stderr = error.stderr?.toString() || ""; + const stdout = error.stdout?.toString() || ""; + const errorMessage = stderr || stdout || error.message || "Failed to pull image"; + throw new Error(errorMessage); + } + } + + /** + * Build a Docker image from Dockerfile content + * @param imageName - The name/tag for the built image (e.g., "myapp:latest") + * @param dockerfileContent - The content of the Dockerfile + * @throws Error with Docker error message if build fails + */ + async buildDockerImage(imageName : string, dockerfileContent : string) { + const fs = await import("fs"); + const os = await import("os"); + const path = await import("path"); + + // Create a temporary directory for the build context + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "dockge-build-")); + const dockerfilePath = path.join(tempDir, "Dockerfile"); + + try { + // Write Dockerfile content to temp directory + fs.writeFileSync(dockerfilePath, dockerfileContent); + + // Build the image + try { + await childProcessAsync.spawn("docker", [ "build", "-t", imageName, tempDir ], { + encoding: "utf-8", + }); + } catch (error : any) { + const stderr = error.stderr?.toString() || ""; + const stdout = error.stdout?.toString() || ""; + const errorMessage = stderr || stdout || error.message || "Failed to build image"; + throw new Error(errorMessage); + } + } finally { + // Clean up temp directory + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch (e) { + // Ignore cleanup errors + } + } + } + + /** + * Prune unused Docker images + * @returns Result of the prune operation + */ + async pruneDockerImages() { + const res = await childProcessAsync.spawn("docker", [ "image", "prune", "-a", "-f" ], { + encoding: "utf-8", + }); + + return { + stdout: res.stdout?.toString() || "", + stderr: res.stderr?.toString() || "", + }; + } + } + diff --git a/frontend/src/lang/en.json b/frontend/src/lang/en.json index 5fd1204a..80c08ce3 100644 --- a/frontend/src/lang/en.json +++ b/frontend/src/lang/en.json @@ -134,5 +134,44 @@ "ConsoleNotEnabledMSG1": "Console is a powerful tool that allows you to execute any commands such as docker, rm within the Dockge's container in this Web UI.", "ConsoleNotEnabledMSG2": "It might be dangerous since this Dockge container is connecting to the host's Docker daemon. Also Dockge could be possibly taken down by commands like rm -rf" , "ConsoleNotEnabledMSG3": "If you understand the risk, you can enable it by setting DOCKGE_ENABLE_CONSOLE=true in the environment variables.", - "confirmLeaveStack": "You are currently editing a stack. Are you sure you want to leave?" + "confirmLeaveStack": "You are currently editing a stack. Are you sure you want to leave?", + "dockerImages": "Docker Images", + "diskUsage": "Disk Usage", + "images": "Images", + "containers": "Containers", + "volumes": "Volumes", + "buildCache": "Build Cache", + "refresh": "Refresh", + "pruneImages": "Prune Images", + "loading": "Loading", + "noImagesFound": "No images found", + "repository": "Repository", + "tag": "Tag", + "imageId": "Image ID", + "created": "Created", + "size": "Size", + "actions": "Actions", + "deleteImage": "Delete Image", + "deleteImageMsg": "Are you sure you want to delete this image?", + "pruneImagesMsg": "This will permanently delete ALL unused images (not just dangling ones).", + "pruneImagesWarning": "This action cannot be undone and may free up significant disk space.", + "warning": "Warning", + "pullImage": "Pull Image", + "imageName": "Image Name", + "imageNamePlaceholder": "nginx:latest", + "imageNameExample": "Example: nginx:latest, redis:alpine, mysql:8.0", + "buildImage": "Build Image", + "buildImageNamePlaceholder": "myapp:latest", + "buildImageNameExample": "Example: myapp:latest, myapp:1.0.0", + "dockerfile": "Dockerfile", + "dockerfilePlaceholder": "FROM alpine:latest\nRUN apk add --no-cache curl\nCMD [\"/bin/sh\"]", + "dockerfileExample": "Paste your Dockerfile content here", + "searchImages": "Search images...", + "pullingImage": "Pulling {0}...", + "buildingImage": "Building {0}...", + "imageInUseError": "This image is currently being used by one or more containers. Please stop and remove the containers first.", + "Image Deleted": "Image Deleted", + "Image Pulled": "Image Pulled", + "Image Built": "Image Built", + "Images Pruned": "Images Pruned" } diff --git a/frontend/src/layouts/Layout.vue b/frontend/src/layouts/Layout.vue index 1cfd8408..2939ad87 100644 --- a/frontend/src/layouts/Layout.vue +++ b/frontend/src/layouts/Layout.vue @@ -27,6 +27,12 @@ + +