diff --git a/backend/dockge-server.ts b/backend/dockge-server.ts index ad9b075..7aaba63 100644 --- a/backend/dockge-server.ts +++ b/backend/dockge-server.ts @@ -30,6 +30,7 @@ import { Cron } from "croner"; import gracefulShutdown from "http-graceful-shutdown"; import User from "./models/user"; import childProcess from "child_process"; +import { Terminal } from "./terminal"; export class DockgeServer { app : Express; @@ -230,6 +231,11 @@ export class DockgeServer { }); + if (isDev) { + setInterval(() => { + log.debug("terminal", "Terminal count: " + Terminal.getTerminalCount()); + }, 5000); + } } async afterLogin(socket : DockgeSocket, user : User) { @@ -292,11 +298,11 @@ export class DockgeServer { log.info("server", `Listening on ${this.config.port}`); } - // Run every 5 seconds - Cron("*/2 * * * * *", { + // Run every 10 seconds + Cron("*/10 * * * * *", { protect: true, // Enabled over-run protection. }, () => { - log.debug("server", "Cron job running"); + //log.debug("server", "Cron job running"); this.sendStackList(true); }); diff --git a/backend/socket-handlers/docker-socket-handler.ts b/backend/socket-handlers/docker-socket-handler.ts index 3683114..047d8f7 100644 --- a/backend/socket-handlers/docker-socket-handler.ts +++ b/backend/socket-handlers/docker-socket-handler.ts @@ -75,7 +75,9 @@ export class DockerSocketHandler extends SocketHandler { const stack = Stack.getStack(server, stackName); - stack.joinCombinedTerminal(socket); + if (stack.isManagedByDockge) { + stack.joinCombinedTerminal(socket); + } callback({ ok: true, diff --git a/backend/socket-handlers/terminal-socket-handler.ts b/backend/socket-handlers/terminal-socket-handler.ts index 0a0485b..bb27a66 100644 --- a/backend/socket-handlers/terminal-socket-handler.ts +++ b/backend/socket-handlers/terminal-socket-handler.ts @@ -140,9 +140,26 @@ export class TerminalSocketHandler extends SocketHandler { } }); - // Close Terminal - socket.on("terminalClose", async (terminalName : unknown, callback : unknown) => { + // Leave Combined Terminal + socket.on("leaveCombinedTerminal", async (stackName : unknown, callback) => { + try { + checkLogin(socket); + log.debug("leaveCombinedTerminal", "Stack name: " + stackName); + + if (typeof(stackName) !== "string") { + throw new ValidationError("Stack name must be a string."); + } + + const stack = Stack.getStack(server, stackName); + await stack.leaveCombinedTerminal(socket); + + callback({ + ok: true, + }); + } catch (e) { + callbackError(e, callback); + } }); // TODO: Resize Terminal diff --git a/backend/stack.ts b/backend/stack.ts index e2873da..a9af442 100644 --- a/backend/stack.ts +++ b/backend/stack.ts @@ -191,6 +191,7 @@ export class Stack { let stacksDir = server.stacksDir; let stackList : Map; + // Use cached stack list? if (useCacheForManaged && this.managedStackList.size > 0) { stackList = this.managedStackList; } else { @@ -220,22 +221,19 @@ export class Stack { this.managedStackList = new Map(stackList); } - // Also get the list from `docker compose ls --all --format json` + // Get status from docker compose ls let res = childProcess.execSync("docker compose ls --all --format json"); let composeList = JSON.parse(res.toString()); for (let composeStack of composeList) { - - // Skip the dockge stack - // TODO: Could be self managed? - if (composeStack.Name === "dockge") { - continue; - } - let stack = stackList.get(composeStack.Name); // This stack probably is not managed by Dockge, but we still want to show it if (!stack) { + // Skip the dockge stack if it is not managed by Dockge + if (composeStack.Name === "dockge") { + continue; + } stack = new Stack(server, composeStack.Name); stackList.set(composeStack.Name, stack); } @@ -300,7 +298,7 @@ export class Stack { } } } else { - log.debug("getStack", "Skip FS operations"); + //log.debug("getStack", "Skip FS operations"); } let stack : Stack; @@ -376,12 +374,21 @@ export class Stack { async joinCombinedTerminal(socket: DockgeSocket) { const terminalName = getCombinedTerminalName(this.name); const terminal = Terminal.getOrCreateTerminal(this.server, terminalName, "docker", [ "compose", "logs", "-f", "--tail", "100" ], this.path); + terminal.enableKeepAlive = true; terminal.rows = COMBINED_TERMINAL_ROWS; terminal.cols = COMBINED_TERMINAL_COLS; terminal.join(socket); terminal.start(); } + async leaveCombinedTerminal(socket: DockgeSocket) { + const terminalName = getCombinedTerminalName(this.name); + const terminal = Terminal.getTerminal(terminalName); + if (terminal) { + terminal.leave(socket); + } + } + async joinContainerTerminal(socket: DockgeSocket, serviceName: string, shell : string = "sh", index: number = 0) { const terminalName = getContainerExecTerminalName(this.name, serviceName, index); let terminal = Terminal.getTerminal(terminalName); diff --git a/backend/terminal.ts b/backend/terminal.ts index f1faf18..2b22e69 100644 --- a/backend/terminal.ts +++ b/backend/terminal.ts @@ -34,6 +34,9 @@ export class Terminal { protected _rows : number = TERMINAL_ROWS; protected _cols : number = TERMINAL_COLS; + public enableKeepAlive : boolean = false; + protected keepAliveInterval? : NodeJS.Timeout; + constructor(server : DockgeServer, name : string, file : string, args : string | string[], cwd : string) { this.server = server; this._name = name; @@ -80,6 +83,25 @@ export class Terminal { return; } + if (this.enableKeepAlive) { + log.debug("Terminal", "Keep alive enabled for terminal " + this.name); + + // Close if there is no clients + this.keepAliveInterval = setInterval(() => { + const clients = this.server.io.sockets.adapter.rooms.get(this.name); + const numClients = clients ? clients.size : 0; + + if (numClients === 0) { + log.debug("Terminal", "Terminal " + this.name + " has no client, closing..."); + this.close(); + } else { + log.debug("Terminal", "Terminal " + this.name + " has " + numClients + " client(s)"); + } + }, 60 * 1000); + } else { + log.debug("Terminal", "Keep alive disabled for terminal " + this.name); + } + try { this._ptyProcess = pty.spawn(this.file, this.args, { name: this.name, @@ -100,6 +122,8 @@ export class Terminal { this._ptyProcess.onExit(this.exit); } catch (error) { if (error instanceof Error) { + clearInterval(this.keepAliveInterval); + log.error("Terminal", "Failed to start terminal: " + error.message); const exitCode = Number(error.message.split(" ").pop()); this.exit({ @@ -122,6 +146,8 @@ export class Terminal { Terminal.terminalMap.delete(this.name); log.debug("Terminal", "Terminal " + this.name + " exited with code " + res.exitCode); + clearInterval(this.keepAliveInterval); + if (this.callback) { this.callback(res.exitCode); } @@ -158,7 +184,9 @@ export class Terminal { } close() { - this._ptyProcess?.kill(); + clearInterval(this.keepAliveInterval); + // Send Ctrl+C to the terminal + this.ptyProcess?.write("\x03"); } /** @@ -193,6 +221,10 @@ export class Terminal { terminal.start(); }); } + + public static getTerminalCount() { + return Terminal.terminalMap.size; + } } /** diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 783d772..708dd4e 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -12,7 +12,6 @@ declare module 'vue' { ArrayInput: typeof import('./src/components/ArrayInput.vue')['default'] ArraySelect: typeof import('./src/components/ArraySelect.vue')['default'] BDropdown: typeof import('bootstrap-vue-next')['BDropdown'] - BDropdownDivider: typeof import('bootstrap-vue-next')['BDropdownDivider'] BDropdownItem: typeof import('bootstrap-vue-next')['BDropdownItem'] BModal: typeof import('bootstrap-vue-next')['BModal'] Confirm: typeof import('./src/components/Confirm.vue')['default'] diff --git a/frontend/src/components/Uptime.vue b/frontend/src/components/Uptime.vue index 858a1a0..ac8dafc 100644 --- a/frontend/src/components/Uptime.vue +++ b/frontend/src/components/Uptime.vue @@ -45,11 +45,12 @@ export default { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 561eb7e..4cdac6d 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -13,6 +13,7 @@ const languageList = { "sl": "Slovenščina", "tr": "Türkçe", "zh-CN": "简体中文", + "zh-TW": "繁體中文(台灣)", "ur": "Urdu", "ko-KR": "한국어", "ru": "Русский", @@ -20,7 +21,8 @@ const languageList = { "ar": "العربية", "th":"ไทย", "it-IT":"Italiano", - "sv-SE":"Svenska" + "sv-SE":"Svenska", + "uk-UA":"Українська" }; let messages = { diff --git a/frontend/src/lang/uk-UA.json b/frontend/src/lang/uk-UA.json new file mode 100644 index 0000000..4a7cdf9 --- /dev/null +++ b/frontend/src/lang/uk-UA.json @@ -0,0 +1,94 @@ +{ + "languageName": "Українська", + "Create your admin account": "Створити акаунт адміністратора", + "authIncorrectCreds": "Невірний логін чи пароль", + "PasswordsDoNotMatch": "Паролі не співпадають.", + "Repeat Password": "Повторіть пароль", + "Create": "Створити", + "signedInDisp": "Авторизовано як {0}", + "signedInDispDisabled": "Авторизацію вимкнено.", + "home": "Головна", + "console": "Консоль", + "registry": "Registry", + "compose": "Compose", + "addFirstStackMsg": "Додайте свій перший стек!", + "stackName" : "Назва стеку", + "deployStack": "Розрознути", + "deleteStack": "Видалити", + "stopStack": "Зупинити", + "restartStack": "Перезапустити", + "updateStack": "Оновити", + "startStack": "Запустити", + "editStack": "Редагувати", + "discardStack": "Відмінити", + "saveStackDraft": "Зберегти", + "notAvailableShort" : "Н/Д", + "deleteStackMsg": "Ви впевнені що хочете видалити цей стек?", + "stackNotManagedByDockgeMsg": "Даний стек не управляється Dockge.", + "primaryHostname": "Назва хосту", + "general": "Загальне", + "container": "Контейнер | Контейнери", + "scanFolder": "Сканувати папку зі стеками", + "dockerImage": "Образ", + "restartPolicyUnlessStopped": "Доки не буде зупинено", + "restartPolicyAlways": "Завжди", + "restartPolicyOnFailure": "При падінні", + "restartPolicyNo": "Ніколи", + "environmentVariable": "Змінна середовища | змінні середовища", + "restartPolicy": "Перезапуск", + "containerName": "Назва контейнеру", + "port": "Порт | Порти", + "volume": "Сховище | Сховища", + "network": "Мережа | Мережі", + "dependsOn": "Залежність контейнера | Залежності контейнеру", + "addListItem": "Додати {0}", + "deleteContainer": "Видалити", + "addContainer": "Додати Контейнер", + "addNetwork": "Додати Мережу", + "disableauth.message1": "Ви впевнені що хочете вимкнути авторизацію?", + "disableauth.message2": "Це призначено для сценаріїв, де ви збираєтесь використати сторонню авторизацію перед Dockge, наприклад Cloudflare Access, Authelia чи інші.", + "passwordNotMatchMsg": "Паролі не співпадають.", + "autoGet": "Отримати", + "add": "Додати", + "Edit": "Змінити", + "applyToYAML": "Застосувати для YAML", + "createExternalNetwork": "Створити", + "addInternalNetwork": "Додати", + "Save": "Зберегти", + "Language": "Мова", + "Current User": "Користувач", + "Change Password": "Змінити пароль", + "Current Password": "Поточний пароль", + "New Password": "Новий пароль", + "Repeat New Password": "Повторіть новий пароль", + "Update Password": "Оновити пароль", + "Advanced": "Розширені опції", + "Please use this option carefully!": "Будь-ласка, використовуйте цю опцію з обережністю!", + "Enable Auth": "Увімкнути автентифікацію", + "Disable Auth": "Вимкнути автентифікацію", + "I understand, please disable": "Зрозуміло, все-одно вимкнути", + "Leave": "Покинути", + "Frontend Version": "Версія інтерфейсу", + "Check Update On GitHub": "Перевірити оновлення на GitHub", + "Show update if available": "Показати оновлення, якщо доступно", + "Also check beta release": "Перевіряти оновлення до бета-версії", + "Remember me": "Запамʼятати мене", + "Login": "Логін", + "Username": "Імʼя користувача", + "Password": "Пароль", + "Settings": "Налаштування", + "Logout": "Вийти", + "Lowercase only": "Тільки нижній регістр", + "Convert to Compose": "Конвертувати в Compose", + "Docker Run": "Запустити Docker", + "active": "активно", + "exited": "завершено", + "inactive": "неактивно", + "Appearance": "Зовнішній вигляд", + "Security": "Безпека", + "About": "Про продукт", + "Allowed commands:": "Дозволені команди:", + "Internal Networks": "Внутрішні мережі", + "External Networks": "Зовнішні мережі", + "No External Networks": "Немає зовнішніх мереж" +} diff --git a/frontend/src/lang/zh-TW.json b/frontend/src/lang/zh-TW.json new file mode 100644 index 0000000..a077b83 --- /dev/null +++ b/frontend/src/lang/zh-TW.json @@ -0,0 +1,94 @@ +{ + "languageName": "繁體中文(台灣)", + "Create your admin account": "建立您的管理員帳號", + "authIncorrectCreds": "使用者名稱或密碼錯誤", + "PasswordsDoNotMatch": "兩次輸入的密碼不一致。", + "Repeat Password": "重複以確認密碼", + "Create": "建立", + "signedInDisp": "目前使用者:{0}", + "signedInDispDisabled": "已停用身份驗證", + "home": "首頁", + "console": "主控台", + "registry": "映像倉庫", + "compose": "Compose", + "addFirstStackMsg": "組合您的第一個堆疊!", + "stackName": "堆疊名稱", + "deployStack": "部署", + "deleteStack": "刪除", + "stopStack": "停止", + "restartStack": "重啟", + "updateStack": "更新", + "startStack": "啟動", + "editStack": "編輯", + "discardStack": "捨棄", + "saveStackDraft": "儲存", + "notAvailableShort": "不可用", + "deleteStackMsg": "您確定要刪除這個堆疊嗎?", + "stackNotManagedByDockgeMsg": "這個堆疊不由 Dockge 管理", + "primaryHostname": "主機名稱", + "general": "一般", + "container": "容器 | 容器群組", + "scanFolder": "掃描堆疊資料夾", + "dockerImage": "映像", + "restartPolicyUnlessStopped": "除非手動停止", + "restartPolicyAlways": "始終", + "restartPolicyOnFailure": "在失敗時", + "restartPolicyNo": "不重啟", + "environmentVariable": "環境變數 | 環境變數群組", + "restartPolicy": "重啟策略", + "containerName": "容器名稱", + "port": "連接埠 | 連接埠群組", + "volume": "資料卷 | 資料卷群組", + "network": "網路 | 網路群組", + "dependsOn": "容器依賴 | 容器依賴關係", + "addListItem": "新增 {0}", + "deleteContainer": "刪除容器", + "addContainer": "新增容器", + "addNetwork": "新增網路", + "disableauth.message1": "您確定要停用身份驗證嗎?", + "disableauth.message2": "該選項設計用於某些場景,例如在 Dockge 之上接入第三方認證,如 Cloudflare Access、Authelia 或其他認證機制。如果您不清楚這個選項的作用,請不要停用驗證!", + "passwordNotMatchMsg": "兩次輸入的密碼不一致。", + "autoGet": "自動取得", + "add": "新增", + "Edit": "編輯", + "applyToYAML": "應用到YAML", + "createExternalNetwork": "建立", + "addInternalNetwork": "新增", + "Save": "儲存", + "Language": "語言", + "Current User": "目前使用者", + "Change Password": "更換密碼", + "Current Password": "目前密碼", + "New Password": "新密碼", + "Repeat New Password": "重複以確認新密碼", + "Update Password": "更新密碼", + "Advanced": "進階", + "Please use this option carefully!": "請謹慎使用該選項!", + "Enable Auth": "啟用驗證", + "Disable Auth": "停用驗證", + "I understand, please disable": "我已了解風險,確認停用", + "Leave": "離開", + "Frontend Version": "前端版本", + "Check Update On GitHub": "在 GitHub 上檢查更新", + "Show update if available": "有更新時提醒我", + "Also check beta release": "同時檢查 Beta 渠道更新", + "Remember me": "記住我", + "Login": "登入", + "Username": "使用者名稱", + "Password": "密碼", + "Settings": "設定", + "Logout": "登出", + "Lowercase only": "僅小寫字母", + "Convert to Compose": "轉換為 Compose 格式", + "Docker Run": "Docker 啟動", + "active": "已啟動", + "exited": "已退出", + "inactive": "未啟動", + "Appearance": "外觀", + "Security": "安全", + "About": "關於", + "Allowed commands:": "允許使用的指令:", + "Internal Networks": "內部網路", + "External Networks": "外部網路", + "No External Networks": "無外部網路" +} diff --git a/frontend/src/pages/Compose.vue b/frontend/src/pages/Compose.vue index afcab69..88db8ce 100644 --- a/frontend/src/pages/Compose.vue +++ b/frontend/src/pages/Compose.vue @@ -319,6 +319,12 @@ export default { }, deep: true, }, + + $route(to, from) { + // Leave Combined Terminal + console.debug("leaveCombinedTerminal", from.params.stackName); + this.$root.getSocket().emit("leaveCombinedTerminal", this.stack.name, () => {}); + } }, mounted() { if (this.isAdd) { @@ -361,7 +367,7 @@ export default { clearTimeout(serviceStatusTimeout); serviceStatusTimeout = setTimeout(async () => { this.requestServiceStatus(); - }, 2000); + }, 5000); }, requestServiceStatus() { @@ -544,10 +550,6 @@ export default { throw new Error("Services must be an object"); } - if (!config.version) { - config.version = "3.8"; - } - this.yamlDoc = doc; this.jsonConfig = config;