From b02f5e092e04541e5b26f0d208a2f59124d83d3d Mon Sep 17 00:00:00 2001 From: Justin Wiebe <56078237+justwiebe@users.noreply.github.com> Date: Sat, 11 Apr 2026 23:42:38 -0500 Subject: [PATCH] Add resource usage stats to the compose page (#700) Co-authored-by: cmcooper1980 <31871143+cmcooper1980@users.noreply.github.com> --- .../docker-socket-handler.ts | 16 ++++ backend/dockge-server.ts | 29 ++++++ backend/stack.ts | 28 +++--- frontend/components.d.ts | 1 + frontend/src/components/Container.vue | 64 +++++++++++-- frontend/src/components/DockerStat.vue | 94 +++++++++++++++++++ frontend/src/icon.ts | 2 + frontend/src/lang/en.json | 5 + frontend/src/pages/Compose.vue | 34 ++++++- 9 files changed, 247 insertions(+), 26 deletions(-) create mode 100644 frontend/src/components/DockerStat.vue diff --git a/backend/agent-socket-handlers/docker-socket-handler.ts b/backend/agent-socket-handlers/docker-socket-handler.ts index ad07c4f..28f0c31 100644 --- a/backend/agent-socket-handlers/docker-socket-handler.ts +++ b/backend/agent-socket-handlers/docker-socket-handler.ts @@ -240,6 +240,22 @@ export class DockerSocketHandler extends AgentSocketHandler { } }); + // Docker stats + agentSocket.on("dockerStats", async (callback) => { + try { + checkLogin(socket); + + const dockerStats = Object.fromEntries(await server.getDockerStats()); + callbackResult({ + ok: true, + dockerStats, + }, callback); + server.sendStackList(); + } catch (e) { + callbackError(e, callback); + } + }); + // Start a service agentSocket.on("startService", async (stackName: unknown, serviceName: unknown, callback) => { try { diff --git a/backend/dockge-server.ts b/backend/dockge-server.ts index 730b69f..206a14a 100644 --- a/backend/dockge-server.ts +++ b/backend/dockge-server.ts @@ -637,6 +637,35 @@ export class DockgeServer { return list; } + async getDockerStats() : Promise> { + let stats = new Map(); + + try { + let res = await childProcessAsync.spawn("docker", [ "stats", "--format", "json", "--no-stream" ], { + encoding: "utf-8", + }); + + if (!res.stdout) { + return stats; + } + + let lines = res.stdout?.toString().split("\n"); + + for (let line of lines) { + try { + let obj = JSON.parse(line); + stats.set(obj.Name, obj); + } catch (e) { + } + } + + return stats; + } catch (e) { + log.error("getDockerStats", e); + return stats; + } + } + get stackDirFullPath() { return path.resolve(this.stacksDir); } diff --git a/backend/stack.ts b/backend/stack.ts index e2c88fb..79a00db 100644 --- a/backend/stack.ts +++ b/backend/stack.ts @@ -508,7 +508,7 @@ export class Stack { } async getServiceStatusList() { - let statusList = new Map(); + let statusList = new Map>(); try { let res = await childProcessAsync.spawn("docker", this.getComposeOptions("ps", "--format", "json"), { @@ -522,22 +522,23 @@ export class Stack { let lines = res.stdout?.toString().split("\n"); + const addLine = (obj: { Service: string, State: string, Name: string, Health: string }) => { + if (!statusList.has(obj.Service)) { + statusList.set(obj.Service, []); + } + statusList.get(obj.Service)?.push({ + status: obj.Health || obj.State, + name: obj.Name + }); + }; + for (let line of lines) { try { let obj = JSON.parse(line); - let ports = (obj.Ports as string).split(/,\s*/).filter((s) => { - return s.indexOf("->") >= 0; - }); - if (obj.Health === "") { - statusList.set(obj.Service, { - state: obj.State, - ports: ports - }); + if (obj instanceof Array) { + obj.forEach(addLine); } else { - statusList.set(obj.Service, { - state: obj.Health, - ports: ports - }); + addLine(obj); } } catch (e) { } @@ -548,7 +549,6 @@ export class Stack { log.error("getServiceStatusList", e); return statusList; } - } async startService(socket: DockgeSocket, serviceName: string) { diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 8ef0c26..e1dea73 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -19,6 +19,7 @@ declare module 'vue' { BModal: typeof import('bootstrap-vue-next')['BModal'] Confirm: typeof import('./src/components/Confirm.vue')['default'] Container: typeof import('./src/components/Container.vue')['default'] + DockerStat: typeof import('./src/components/DockerStat.vue')['default'] General: typeof import('./src/components/settings/General.vue')['default'] GlobalEnv: typeof import('./src/components/settings/GlobalEnv.vue')['default'] HiddenInput: typeof import('./src/components/HiddenInput.vue')['default'] diff --git a/frontend/src/components/Container.vue b/frontend/src/components/Container.vue index 4da735c..302e9cc 100644 --- a/frontend/src/components/Container.vue +++ b/frontend/src/components/Container.vue @@ -58,6 +58,32 @@ {{ $t("deleteContainer") }} +
+
+ +
+ +
+
+ +
+ +
+
+
@@ -161,10 +187,12 @@ import { defineComponent } from "vue"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { parseDockerPort } from "../../../common/util-common"; +import DockerStat from "./DockerStat.vue"; export default defineComponent({ components: { FontAwesomeIcon, + DockerStat }, props: { name: { @@ -179,16 +207,12 @@ export default defineComponent({ type: Boolean, default: false, }, - status: { - type: String, - default: "N/A", + serviceStatus: { + type: Object, + default: null, }, - processing: { - type: Boolean, - default: false, - }, - ports: { - type: Array, + dockerStats: { + type: Object, default: null } }, @@ -200,6 +224,7 @@ export default defineComponent({ data() { return { showConfig: false, + expandedStats: false, }; }, computed: { @@ -304,6 +329,22 @@ export default defineComponent({ return ""; } }, + statsInstances() { + if (!this.serviceStatus) { + return []; + } + + return this.serviceStatus + .map(s => this.dockerStats[s.name]) + .filter(s => !!s) + .sort((a, b) => a.Name.localeCompare(b.Name)); + }, + status() { + if (!this.serviceStatus) { + return "N/A"; + } + return this.serviceStatus[0].status; + } }, mounted() { if (this.first) { @@ -356,5 +397,10 @@ export default defineComponent({ align-items: center; justify-content: end; } + + .stats { + font-size: 0.8rem; + color: #6c757d; + } } diff --git a/frontend/src/components/DockerStat.vue b/frontend/src/components/DockerStat.vue new file mode 100644 index 0000000..36a82b8 --- /dev/null +++ b/frontend/src/components/DockerStat.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/frontend/src/icon.ts b/frontend/src/icon.ts index 588b3fc..396f402 100644 --- a/frontend/src/icon.ts +++ b/frontend/src/icon.ts @@ -38,6 +38,7 @@ import { faAward, faLink, faChevronDown, + faChevronUp, faSignOutAlt, faPen, faExternalLinkSquareAlt, @@ -90,6 +91,7 @@ library.add( faAward, faLink, faChevronDown, + faChevronUp, faSignOutAlt, faPen, faExternalLinkSquareAlt, diff --git a/frontend/src/lang/en.json b/frontend/src/lang/en.json index e703f6d..e521043 100644 --- a/frontend/src/lang/en.json +++ b/frontend/src/lang/en.json @@ -133,6 +133,11 @@ "Network name...": "Network name...", "Select a network...": "Select a network...", "NoNetworksAvailable": "No networks available. You need to add internal networks or enable external networks in the right side first.", + "CPU": "CPU", + "memory": "Memory", + "memoryAbbreviated": "Mem", + "networkIO": "Network I/O", + "blockIO": "Block I/O", "Console is not enabled": "Console is not enabled", "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" , diff --git a/frontend/src/pages/Compose.vue b/frontend/src/pages/Compose.vue index aaf6c29..41834bf 100644 --- a/frontend/src/pages/Compose.vue +++ b/frontend/src/pages/Compose.vue @@ -128,12 +128,11 @@ :name="name" :is-edit-mode="isEditMode" :first="name === Object.keys(jsonConfig.services)[0]" - :processing="processing" + :serviceStatus="serviceStatusList[name]" + :dockerStats="dockerStats" @start-service="startService" @stop-service="stopService" @restart-service="restartService" - :status="serviceStatusList[name]?.state" - :ports="serviceStatusList[name]?.ports" />
@@ -282,6 +281,12 @@ const envDefault = "# VARIABLE=value #comment"; let yamlErrorTimeout = null; let serviceStatusTimeout = null; +let dockerStatsTimeout = null; +let prismjsSymbolDefinition = { + "symbol": { + pattern: /(? { + this.requestDockerStats(); + }, 5000); + }, + requestServiceStatus() { // Do not request if it is add mode if (this.isAdd) { @@ -536,6 +551,17 @@ export default { }); }, + requestDockerStats() { + this.$root.emitAgent(this.endpoint, "dockerStats", (res) => { + if (res.ok) { + this.dockerStats = res.dockerStats; + } + if (!this.stopDockerStatsTimeout) { + this.startDockerStatsTimeout(); + } + }); + }, + exitConfirm(next) { if (this.isEditMode) { if (confirm(this.$t("confirmLeaveStack"))) { @@ -553,7 +579,9 @@ export default { exitAction() { console.log("exitAction"); this.stopServiceStatusTimeout = true; + this.stopDockerStatsTimeout = true; clearTimeout(serviceStatusTimeout); + clearTimeout(dockerStatsTimeout); // Leave Combined Terminal console.debug("leaveCombinedTerminal", this.endpoint, this.stack.name);