diff --git a/README.md b/README.md index 82c81b6..74e0f6a 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ A fancy, easy-to-use and reactive docker stack (`docker-compose.yml`) manager. - Interactive web terminal for containers and any docker commands - Reactive - Everything is just responsive. Progress and terminal output are in real-time - Easy-to-use & fancy UI - If you love Uptime Kuma's UI, you will love this too +- Build on top of [Compose V2](https://docs.docker.com/compose/migrate/), as known as `compose.yaml` and `docker compose` ## Installation @@ -30,4 +31,6 @@ If you love this project, please consider giving this project a ⭐. - Container stats - Get app icons - Switch Docker context +- Support Dockerfile and build - Zero-config private docker registry +- Support Docker swarm diff --git a/backend/dockge-server.ts b/backend/dockge-server.ts index fd13e22..1a3e801 100644 --- a/backend/dockge-server.ts +++ b/backend/dockge-server.ts @@ -26,6 +26,8 @@ import expressStaticGzip from "express-static-gzip"; import path from "path"; import { TerminalSocketHandler } from "./socket-handlers/terminal-socket-handler"; import { Stack } from "./stack"; +import { Cron } from "croner"; +import childProcess from "child_process"; export class DockgeServer { app : Express; @@ -248,6 +250,15 @@ export class DockgeServer { } else { log.info("server", `Listening on ${this.config.port}`); } + + // Run every 5 seconds + const job = Cron("*/2 * * * * *", { + protect: true, // Enabled over-run protection. + }, () => { + log.debug("server", "Cron job running"); + this.sendStackList(true); + }); + }); } @@ -412,18 +423,40 @@ export class DockgeServer { return jwtSecretBean; } - sendStackList(socket : DockgeSocket) { - let room = socket.userID.toString(); - let stackList = Stack.getStackList(this); - let list = {}; - - for (let stack of stackList) { - list[stack.name] = stack.toSimpleJSON(); + sendStackList(useCache = false) { + let stackList = Stack.getStackList(this, useCache); + let roomList = this.io.sockets.adapter.rooms.keys(); + for (let room of roomList) { + // Check if the room is a number (user id) + if (Number(room)) { + this.io.to(room).emit("stackList", { + ok: true, + stackList: Object.fromEntries(stackList), + }); + } } + } - this.io.to(room).emit("stackList", { - ok: true, - stackList: list, - }); + sendStackStatusList() { + let statusList = Stack.getStatusList(); + + let roomList = this.io.sockets.adapter.rooms.keys(); + + for (let room of roomList) { + // Check if the room is a number (user id) + if (Number(room)) { + log.debug("server", "Send stack status list to room " + room); + this.io.to(room).emit("stackStatusList", { + ok: true, + stackStatusList: Object.fromEntries(statusList), + }); + } else { + log.debug("server", "Skip sending stack status list to room " + room); + } + } + } + + get stackDirFullPath() { + return path.resolve(this.stacksDir); } } diff --git a/backend/socket-handlers/docker-socket-handler.ts b/backend/socket-handlers/docker-socket-handler.ts index 755b412..3fd4dd0 100644 --- a/backend/socket-handlers/docker-socket-handler.ts +++ b/backend/socket-handlers/docker-socket-handler.ts @@ -1,18 +1,6 @@ import { SocketHandler } from "../socket-handler.js"; import { DockgeServer } from "../dockge-server"; import { callbackError, checkLogin, DockgeSocket, ValidationError } from "../util-server"; -import { log } from "../log"; -import yaml from "yaml"; -import path from "path"; -import fs from "fs"; -import { - allowedCommandList, - allowedRawKeys, - getComposeTerminalName, - isDev, - PROGRESS_TERMINAL_ROWS -} from "../util-common"; -import { Terminal } from "../terminal"; import { Stack } from "../stack"; export class DockerSocketHandler extends SocketHandler { @@ -23,6 +11,7 @@ export class DockerSocketHandler extends SocketHandler { checkLogin(socket); const stack = this.saveStack(socket, server, name, composeYAML, isAdd); await stack.deploy(socket); + server.sendStackList(); callback({ ok: true, }); @@ -39,7 +28,33 @@ export class DockerSocketHandler extends SocketHandler { ok: true, "msg": "Saved" }); - server.sendStackList(socket); + server.sendStackList(); + } catch (e) { + callbackError(e, callback); + } + }); + + socket.on("deleteStack", async (name : unknown, callback) => { + try { + checkLogin(socket); + if (typeof(name) !== "string") { + throw new ValidationError("Name must be a string"); + } + const stack = Stack.getStack(server, name); + + try { + await stack.delete(socket); + } catch (e) { + server.sendStackList(); + throw e; + } + + server.sendStackList(); + callback({ + ok: true, + msg: "Deleted" + }); + } catch (e) { callbackError(e, callback); } diff --git a/backend/socket-handlers/terminal-socket-handler.ts b/backend/socket-handlers/terminal-socket-handler.ts index 1bbb1a9..3322799 100644 --- a/backend/socket-handlers/terminal-socket-handler.ts +++ b/backend/socket-handlers/terminal-socket-handler.ts @@ -5,8 +5,14 @@ import { log } from "../log"; import yaml from "yaml"; import path from "path"; import fs from "fs"; -import { allowedCommandList, allowedRawKeys, isDev } from "../util-common"; -import { Terminal } from "../terminal"; +import { + allowedCommandList, + allowedRawKeys, + getComposeTerminalName, + isDev, + PROGRESS_TERMINAL_ROWS +} from "../util-common"; +import { MainTerminal, Terminal } from "../terminal"; export class TerminalSocketHandler extends SocketHandler { create(socket : DockgeSocket, server : DockgeServer) { @@ -57,12 +63,36 @@ export class TerminalSocketHandler extends SocketHandler { }); // Create Terminal - socket.on("terminalCreate", async (terminalName : unknown, callback : unknown) => { + socket.on("mainTerminal", async (terminalName : unknown, callback) => { + try { + checkLogin(socket); + if (typeof(terminalName) !== "string") { + throw new ValidationError("Terminal name must be a string."); + } + log.debug("deployStack", "Terminal name: " + terminalName); + + let terminal = Terminal.getTerminal(terminalName); + + if (!terminal) { + terminal = new MainTerminal(server, terminalName); + terminal.rows = 50; + log.debug("deployStack", "Terminal created"); + } + + terminal.join(socket); + terminal.start(); + + callback({ + ok: true, + }); + } catch (e) { + callbackError(e, callback); + } }); // Join Terminal - socket.on("terminalJoin", async (terminalName : unknown, callback : unknown) => { + socket.on("terminalJoin", async (terminalName : unknown, callback) => { if (typeof(callback) !== "function") { log.debug("console", "Callback is not a function."); return; diff --git a/backend/stack.ts b/backend/stack.ts index 893f205..04defc5 100644 --- a/backend/stack.ts +++ b/backend/stack.ts @@ -4,15 +4,28 @@ import { log } from "./log"; import yaml from "yaml"; import { DockgeSocket, ValidationError } from "./util-server"; import path from "path"; -import { getComposeTerminalName, PROGRESS_TERMINAL_ROWS } from "./util-common"; +import { + CREATED_FILE, + CREATED_STACK, + EXITED, + getComposeTerminalName, + PROGRESS_TERMINAL_ROWS, + RUNNING, + UNKNOWN +} from "./util-common"; import { Terminal } from "./terminal"; +import childProcess from "child_process"; export class Stack { name: string; + protected _status: number = UNKNOWN; protected _composeYAML?: string; + protected _configFilePath?: string; protected server: DockgeServer; + protected static managedStackList: Map = new Map(); + constructor(server : DockgeServer, name : string, composeYAML? : string) { this.name = name; this.server = server; @@ -24,16 +37,30 @@ export class Stack { return { ...obj, composeYAML: this.composeYAML, + isManagedByDockge: this.isManagedByDockge, }; } toSimpleJSON() : object { return { name: this.name, + status: this._status, tags: [], }; } + get isManagedByDockge() : boolean { + if (this._configFilePath) { + return this._configFilePath.startsWith(this.server.stackDirFullPath) && fs.existsSync(this.path) && fs.statSync(this.path).isDirectory(); + } else { + return false; + } + } + + get status() : number { + return this._status; + } + validate() { // Check name, allows [a-z][A-Z][0-9] _ - only if (!this.name.match(/^[a-zA-Z0-9_-]+$/)) { @@ -101,7 +128,7 @@ export class Stack { fs.writeFileSync(path.join(dir, "compose.yaml"), this.composeYAML); } - async deploy(socket? : DockgeSocket) : Promise { + deploy(socket? : DockgeSocket) : Promise { const terminalName = getComposeTerminalName(this.name); log.debug("deployStack", "Terminal name: " + terminalName); @@ -129,30 +156,138 @@ export class Stack { }); } - static getStackList(server : DockgeServer) : Stack[] { - let stacksDir = server.stacksDir; - let stackList : Stack[] = []; + delete(socket?: DockgeSocket) : Promise { + // Docker compose down + const terminalName = getComposeTerminalName(this.name); + log.debug("deleteStack", "Terminal name: " + terminalName); - // Scan the stacks directory, and get the stack list - let filenameList = fs.readdirSync(stacksDir); + const terminal = new Terminal(this.server, terminalName, "docker-compose", [ "down" ], this.path); - log.debug("stack", filenameList); + terminal.rows = PROGRESS_TERMINAL_ROWS; - for (let filename of filenameList) { - let relativePath = path.join(stacksDir, filename); - if (fs.statSync(relativePath).isDirectory()) { - let stack = new Stack(server, filename); - stackList.push(stack); - } + if (socket) { + terminal.join(socket); + log.debug("deployStack", "Terminal joined"); + } else { + log.debug("deployStack", "No socket, not joining"); } + + return new Promise((resolve, reject) => { + terminal.onExit((exitCode : number) => { + if (exitCode === 0) { + // Remove the stack folder + try { + fs.rmSync(this.path, { + recursive: true, + force: true + }); + resolve(exitCode); + } catch (e) { + reject(e); + } + } else { + reject(new Error("Failed to delete, please check the terminal output for more information.")); + } + }); + terminal.start(); + }); + + } + + static getStackList(server : DockgeServer, useCacheForManaged = false) : Map { + let stacksDir = server.stacksDir; + let stackList : Map; + + if (useCacheForManaged && this.managedStackList.size > 0) { + stackList = this.managedStackList; + } else { + stackList = new Map(); + + // Scan the stacks directory, and get the stack list + let filenameList = fs.readdirSync(stacksDir); + + for (let filename of filenameList) { + try { + let stack = this.getStack(server, filename); + stack._status = CREATED_FILE; + stackList.set(filename, stack); + } catch (e) { + log.warn("getStackList", `Failed to get stack ${filename}, error: ${e.message}`); + } + } + + // Cache by copying + this.managedStackList = new Map(stackList); + } + + // Also get the list from `docker compose ls --all --format json` + let res = childProcess.execSync("docker compose ls --all --format json"); + let composeList = JSON.parse(res.toString()); + + for (let composeStack of composeList) { + let stack = stackList.get(composeStack.Name); + + // This stack probably is not managed by Dockge, but we still want to show it + if (!stack) { + stack = new Stack(server, composeStack.Name); + stackList.set(composeStack.Name, stack); + } + + stack._status = this.statusConvert(composeStack.Status); + stack._configFilePath = composeStack.ConfigFiles; + } + return stackList; } + /** + * Get the status list, it will be used to update the status of the stacks + * Not all status will be returned, only the stack that is deployed or created to `docker compose` will be returned + */ + static getStatusList() : Map { + let statusList = new Map(); + + let res = childProcess.execSync("docker compose ls --all --format json"); + let composeList = JSON.parse(res.toString()); + + for (let composeStack of composeList) { + statusList.set(composeStack.Name, this.statusConvert(composeStack.Status)); + } + + return statusList; + } + + static statusConvert(status : string) : number { + if (status.startsWith("created")) { + return CREATED_STACK; + } else if (status.startsWith("running")) { + return RUNNING; + } else if (status.startsWith("exited")) { + return EXITED; + } else { + return UNKNOWN; + } + } + static getStack(server: DockgeServer, stackName: string) : Stack { let dir = path.join(server.stacksDir, stackName); - if (!fs.existsSync(dir)) { - throw new ValidationError("Stack not found"); + + if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) { + // Maybe it is a stack managed by docker compose directly + let stackList = this.getStackList(server); + let stack = stackList.get(stackName); + + if (stack) { + return stack; + } else { + // Really not found + throw new ValidationError("Stack not found"); + } } - return new Stack(server, stackName); + + let stack = new Stack(server, stackName); + stack._status = UNKNOWN; + stack._configFilePath = path.resolve(dir); + return stack; } } diff --git a/backend/terminal.ts b/backend/terminal.ts index 3092420..278325d 100644 --- a/backend/terminal.ts +++ b/backend/terminal.ts @@ -47,6 +47,10 @@ export class Terminal { } public start() { + if (this._ptyProcess) { + return; + } + this._ptyProcess = pty.spawn(this.file, this.args, { name: this.name, cwd: this.cwd, @@ -139,10 +143,12 @@ export class MainTerminal extends InteractiveTerminal { constructor(server : DockgeServer, name : string, cwd : string = "./") { let shell; - if (commandExistsSync("pwsh")) { - shell = "pwsh"; - } else if (os.platform() === "win32") { - shell = "powershell.exe"; + if (os.platform() === "win32") { + if (commandExistsSync("pwsh.exe")) { + shell = "pwsh.exe"; + } else { + shell = "powershell.exe"; + } } else { shell = "bash"; } diff --git a/backend/util-common.ts b/backend/util-common.ts index 3fd88be..d918d19 100644 --- a/backend/util-common.ts +++ b/backend/util-common.ts @@ -1,7 +1,15 @@ -// For loading dayjs plugins, don't remove event though it is not used in this file +/* + * Common utilities for backend and frontend + */ + +// Init dayjs import dayjs from "dayjs"; import timezone from "dayjs/plugin/timezone"; import utc from "dayjs/plugin/utc"; +import relativeTime from "dayjs/plugin/relativeTime"; +dayjs.extend(utc); +dayjs.extend(timezone); +dayjs.extend(relativeTime); let randomBytes : (numBytes: number) => Uint8Array; @@ -17,6 +25,58 @@ if (typeof window !== "undefined" && window.crypto) { randomBytes = (await import("node:crypto")).randomBytes; } +// Stack Status +export const UNKNOWN = 0; +export const CREATED_FILE = 1; +export const CREATED_STACK = 2; +export const RUNNING = 3; +export const EXITED = 4; + +export function statusName(status : number) : string { + switch (status) { + case CREATED_FILE: + return "draft"; + case CREATED_STACK: + return "created_stack"; + case RUNNING: + return "running"; + case EXITED: + return "exited"; + default: + return "unknown"; + } +} + +export function statusNameShort(status : number) : string { + switch (status) { + case CREATED_FILE: + return "draft"; + case CREATED_STACK: + return "inactive"; + case RUNNING: + return "active"; + case EXITED: + return "inactive"; + default: + return "?"; + } +} + +export function statusColor(status : number) : string { + switch (status) { + case CREATED_FILE: + return "dark"; + case CREATED_STACK: + return "danger"; + case RUNNING: + return "primary"; + case EXITED: + return "danger"; + default: + return "secondary"; + } +} + export const isDev = process.env.NODE_ENV === "development"; export const TERMINAL_COLS = 80; export const TERMINAL_ROWS = 10; @@ -30,6 +90,7 @@ export const allowedCommandList : string[] = [ "cd", "dir", ]; + export const allowedRawKeys = [ "\u0003", // Ctrl + C ]; diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 93e131e..8d67441 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -7,6 +7,8 @@ export {} declare module 'vue' { export interface GlobalComponents { + BButton: typeof import('bootstrap-vue-next')['BButton'] + BModal: typeof import('bootstrap-vue-next')['BModal'] Confirm: typeof import('./src/components/Confirm.vue')['default'] Login: typeof import('./src/components/Login.vue')['default'] RouterLink: typeof import('vue-router')['RouterLink'] diff --git a/frontend/src/components/StackList.vue b/frontend/src/components/StackList.vue index 0e31bfa..fce0652 100644 --- a/frontend/src/components/StackList.vue +++ b/frontend/src/components/StackList.vue @@ -71,6 +71,7 @@ @@ -55,4 +47,8 @@ export default { .badge { min-width: 62px; } + +.fixed-width { + width: 62px; +} diff --git a/frontend/src/lang/en.json b/frontend/src/lang/en.json index 57a07e0..8dcb89d 100644 --- a/frontend/src/lang/en.json +++ b/frontend/src/lang/en.json @@ -18,5 +18,7 @@ "editStack": "Edit", "discardStack": "Discard", "saveStackDraft": "Save", - "notAvailableShort" : "N/A" + "notAvailableShort" : "N/A", + "deleteStackMsg": "Are you sure you want to delete this stack?", + "stackNotManagedByDockgeMsg": "This stack is not managed by Dockge." } diff --git a/frontend/src/main.ts b/frontend/src/main.ts index 6acbef5..bad068a 100644 --- a/frontend/src/main.ts +++ b/frontend/src/main.ts @@ -3,6 +3,7 @@ import App from "./App.vue"; import { router } from "./router"; import { FontAwesomeIcon } from "./icon.js"; import { i18n } from "./i18n"; +await import("../../backend/util-common"); // Dependencies import "bootstrap"; @@ -25,10 +26,6 @@ import socket from "./mixins/socket"; import lang from "./mixins/lang"; import theme from "./mixins/theme"; -dayjs.extend(utc); -dayjs.extend(timezone); -dayjs.extend(relativeTime); - const app = createApp(rootApp()); app.use(Toast, { diff --git a/frontend/src/mixins/socket.ts b/frontend/src/mixins/socket.ts index 3f99e7e..cd764fd 100644 --- a/frontend/src/mixins/socket.ts +++ b/frontend/src/mixins/socket.ts @@ -203,6 +203,20 @@ export default defineComponent({ this.stackList = res.stackList; } }); + + socket.on("stackStatusList", (res) => { + if (res.ok) { + + console.log(res.stackStatusList); + + for (let stackName in res.stackStatusList) { + const stackObj = this.stackList[stackName]; + if (stackObj) { + stackObj.status = res.stackStatusList[stackName]; + } + } + } + }); }, /** diff --git a/frontend/src/pages/Compose.vue b/frontend/src/pages/Compose.vue index 8186984..21ec6b6 100644 --- a/frontend/src/pages/Compose.vue +++ b/frontend/src/pages/Compose.vue @@ -2,9 +2,9 @@

Compose

-

Stack: {{ stack.name }}

+

{{ stack.name }}

-
+
+ - +
@@ -33,18 +34,20 @@ :allow-input="false" class="mb-3 terminal" :rows="progressTerminalRows" - @has-data="showProgressTerminal = true" + @has-data="showProgressTerminal = true; submitted = true;" > -
+
-

General

-
- -
- - +
+

General

+
+ +
+ + +
@@ -58,8 +61,9 @@

compose.yaml

-
- + +
+
{{ yamlError }} @@ -75,6 +79,15 @@
-->
+ +
+ {{ $t("stackNotManagedByDockgeMsg") }} +
+ + + + {{ $t("deleteStackMsg") }} +
@@ -89,6 +102,7 @@ import "prismjs/themes/prism-tomorrow.css"; import "vue-prism-editor/dist/prismeditor.min.css"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { getComposeTerminalName, PROGRESS_TERMINAL_ROWS } from "../../../backend/util-common"; +import { BModal } from "bootstrap-vue-next"; const template = `version: "3.8" services: @@ -104,6 +118,7 @@ export default { components: { FontAwesomeIcon, PrismEditor, + BModal, }, data() { return { @@ -112,15 +127,26 @@ export default { processing: true, showProgressTerminal: false, progressTerminalRows: PROGRESS_TERMINAL_ROWS, - stack: {}, + stack: { + + }, isEditMode: false, submitted: false, + showDeleteDialog: false, }; }, computed: { isAdd() { return this.$route.path === "/compose" && !this.submitted; }, + + /** + * Get the stack from the global stack list, because it may contain more real-time data like status + * @return {*} + */ + globalStack() { + return this.$root.stackList[this.stack.name]; + }, }, watch: { "stack.composeYAML": { @@ -139,6 +165,7 @@ export default { this.stack = { name: "", composeYAML: template, + isManagedByDockge: true, }; } else { @@ -147,11 +174,17 @@ export default { } }, methods: { + bindTerminal() { + // Bind Terminal output + const terminalName = getComposeTerminalName(this.stack.name); + this.$refs.progressTerminal.bind(terminalName); + }, loadStack() { this.$root.getSocket().emit("getStack", this.stack.name, (res) => { if (res.ok) { this.stack = res.stack; this.processing = false; + this.bindTerminal(); } else { this.$root.toastRes(res); } @@ -160,9 +193,7 @@ export default { deployStack() { this.processing = true; - // Bind Terminal output - const terminalName = getComposeTerminalName(this.stack.name); - this.$refs.progressTerminal.bind(terminalName); + this.bindTerminal(); this.$root.getSocket().emit("deployStack", this.stack.name, this.stack.composeYAML, this.isAdd, (res) => { this.processing = false; @@ -170,8 +201,6 @@ export default { if (res.ok) { this.$router.push("/compose/" + this.stack.name); - } else { - this.submitted = true; } }); }, @@ -187,6 +216,14 @@ export default { } }); }, + deleteDialog() { + this.$root.getSocket().emit("deleteStack", this.stack.name, (res) => { + this.$root.toastRes(res); + if (res.ok) { + this.$router.push("/"); + } + }); + }, discardStack() { this.loadStack(); @@ -221,4 +258,8 @@ export default { .terminal { height: 200px; } + +.editor-box.edit-mode { + background-color: #2c2f38 !important; +} diff --git a/frontend/src/pages/Console.vue b/frontend/src/pages/Console.vue index 1b96e12..5d17f98 100644 --- a/frontend/src/pages/Console.vue +++ b/frontend/src/pages/Console.vue @@ -9,7 +9,7 @@

- +
@@ -20,7 +20,16 @@ export default { components: { }, mounted() { - this.$root.terminalFit(50); + // Bind Terminal Component to Socket.io + const terminalName = "console"; + this.$refs.terminal.bind(terminalName); + + // Create a new Terminal + this.$root.getSocket().emit("mainTerminal", terminalName, (res) => { + if (!res.ok) { + this.$root.toastRes(res); + } + }); }, methods: { diff --git a/package.json b/package.json index 5dedf09..9377c75 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "check-password-strength": "~2.0.7", "command-exists": "^1.2.9", "compare-versions": "~6.1.0", + "croner": "^7.0.4", "dayjs": "^1.11.10", "express": "~4.18.2", "express-static-gzip": "~2.1.7", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3c0c053..9665f6a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ dependencies: compare-versions: specifier: ~6.1.0 version: 6.1.0 + croner: + specifier: ^7.0.4 + version: 7.0.4 dayjs: specifier: ^1.11.10 version: 1.11.10 @@ -1783,6 +1786,11 @@ packages: dev: false optional: true + /croner@7.0.4: + resolution: {integrity: sha512-P8Zd88km8oQ0xH8Es0u75GtOnFyCNopuAhlFv5kAnbcTuXd0xNvRTgnxnJEs63FicCOsHTL7rpu4BHzY3cMq4w==} + engines: {node: '>=6.0'} + dev: false + /cross-env@7.0.3: resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'}