Add resource usage stats to the compose page (#700)

Co-authored-by: cmcooper1980 <31871143+cmcooper1980@users.noreply.github.com>
This commit is contained in:
Justin Wiebe
2026-04-11 23:42:38 -05:00
committed by GitHub
parent 078f762631
commit b02f5e092e
9 changed files with 247 additions and 26 deletions

View File

@@ -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 // Start a service
agentSocket.on("startService", async (stackName: unknown, serviceName: unknown, callback) => { agentSocket.on("startService", async (stackName: unknown, serviceName: unknown, callback) => {
try { try {

View File

@@ -637,6 +637,35 @@ export class DockgeServer {
return list; return list;
} }
async getDockerStats() : Promise<Map<string, object>> {
let stats = new Map<string, object>();
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() { get stackDirFullPath() {
return path.resolve(this.stacksDir); return path.resolve(this.stacksDir);
} }

View File

@@ -508,7 +508,7 @@ export class Stack {
} }
async getServiceStatusList() { async getServiceStatusList() {
let statusList = new Map<string, { state: string, ports: string[] }>(); let statusList = new Map<string, Array<object>>();
try { try {
let res = await childProcessAsync.spawn("docker", this.getComposeOptions("ps", "--format", "json"), { 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"); 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) { for (let line of lines) {
try { try {
let obj = JSON.parse(line); let obj = JSON.parse(line);
let ports = (obj.Ports as string).split(/,\s*/).filter((s) => { if (obj instanceof Array) {
return s.indexOf("->") >= 0; obj.forEach(addLine);
});
if (obj.Health === "") {
statusList.set(obj.Service, {
state: obj.State,
ports: ports
});
} else { } else {
statusList.set(obj.Service, { addLine(obj);
state: obj.Health,
ports: ports
});
} }
} catch (e) { } catch (e) {
} }
@@ -548,7 +549,6 @@ export class Stack {
log.error("getServiceStatusList", e); log.error("getServiceStatusList", e);
return statusList; return statusList;
} }
} }
async startService(socket: DockgeSocket, serviceName: string) { async startService(socket: DockgeSocket, serviceName: string) {

View File

@@ -19,6 +19,7 @@ declare module 'vue' {
BModal: typeof import('bootstrap-vue-next')['BModal'] BModal: typeof import('bootstrap-vue-next')['BModal']
Confirm: typeof import('./src/components/Confirm.vue')['default'] Confirm: typeof import('./src/components/Confirm.vue')['default']
Container: typeof import('./src/components/Container.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'] General: typeof import('./src/components/settings/General.vue')['default']
GlobalEnv: typeof import('./src/components/settings/GlobalEnv.vue')['default'] GlobalEnv: typeof import('./src/components/settings/GlobalEnv.vue')['default']
HiddenInput: typeof import('./src/components/HiddenInput.vue')['default'] HiddenInput: typeof import('./src/components/HiddenInput.vue')['default']

View File

@@ -58,6 +58,32 @@
{{ $t("deleteContainer") }} {{ $t("deleteContainer") }}
</button> </button>
</div> </div>
<div v-else-if="statsInstances.length > 0" class="mt-2">
<div class="d-flex align-items-center gap-3">
<template v-if="!expandedStats">
<div class="stats">
{{ $t('CPU') }}: {{ statsInstances[0].CPUPerc }}
</div>
<div class="stats">
{{ $t('memoryAbbreviated') }}: {{ statsInstances[0].MemUsage }}
</div>
</template>
<div class="d-flex flex-grow-1 justify-content-end">
<button class="btn btn-sm btn-normal" @click="expandedStats = !expandedStats">
<font-awesome-icon :icon="expandedStats ? 'chevron-up' : 'chevron-down'" />
</button>
</div>
</div>
<transition name="slide-fade" appear>
<div v-if="expandedStats" class="d-flex flex-column gap-3 mt-2">
<DockerStat
v-for="stat in statsInstances"
:key="stat.Name"
:stat="stat"
/>
</div>
</transition>
</div>
<transition name="slide-fade" appear> <transition name="slide-fade" appear>
<div v-if="isEditMode && showConfig" class="config mt-3"> <div v-if="isEditMode && showConfig" class="config mt-3">
@@ -161,10 +187,12 @@
import { defineComponent } from "vue"; import { defineComponent } from "vue";
import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome";
import { parseDockerPort } from "../../../common/util-common"; import { parseDockerPort } from "../../../common/util-common";
import DockerStat from "./DockerStat.vue";
export default defineComponent({ export default defineComponent({
components: { components: {
FontAwesomeIcon, FontAwesomeIcon,
DockerStat
}, },
props: { props: {
name: { name: {
@@ -179,16 +207,12 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
status: { serviceStatus: {
type: String, type: Object,
default: "N/A", default: null,
}, },
processing: { dockerStats: {
type: Boolean, type: Object,
default: false,
},
ports: {
type: Array,
default: null default: null
} }
}, },
@@ -200,6 +224,7 @@ export default defineComponent({
data() { data() {
return { return {
showConfig: false, showConfig: false,
expandedStats: false,
}; };
}, },
computed: { computed: {
@@ -304,6 +329,22 @@ export default defineComponent({
return ""; 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() { mounted() {
if (this.first) { if (this.first) {
@@ -356,5 +397,10 @@ export default defineComponent({
align-items: center; align-items: center;
justify-content: end; justify-content: end;
} }
.stats {
font-size: 0.8rem;
color: #6c757d;
}
} }
</style> </style>

View File

@@ -0,0 +1,94 @@
<template>
<div class="stats-container">
<div class="stats-title">
{{ stat.Name }}
</div>
<div class="d-flex justify-content-between stats gap-2 mt-1">
<div class="stat">
<div class="stat-label">
{{ $t('CPU') }}
</div>
<div>
{{ stat.CPUPerc }}
</div>
</div>
<div class="stat">
<div class="stat-label">
{{ $t('memory') }}
</div>
<div>
{{ stat.MemUsage }} ({{ stat.MemPerc }})
</div>
</div>
<div class="stat">
<div class="stat-label">
{{ $t('networkIO') }}
</div>
<div>
{{ stat.NetIO }}
</div>
</div>
<div class="stat">
<div class="stat-label">
{{ $t('blockIO') }}
</div>
<div>
{{ stat.BlockIO }}
</div>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
stat: {
type: Object,
required: true
}
},
};
</script>
<style lang="scss" scoped>
.stats-container {
container-type: inline-size;
.stats {
container-type: inline-size;
.stat {
display: flex;
flex-direction: column;
gap: 4px;
}
@container (width < 420px) {
flex-direction: column;
.stat {
flex-direction: row;
}
.stat-label::after {
content: ':'
}
}
}
}
.stats {
font-size: 0.8rem;
color: #6c757d;
}
.stat-label {
font-weight: bold;
}
.stats-title {
font-size: 0.9rem;
color: var(--bs-heading-color);
}
</style>

View File

@@ -38,6 +38,7 @@ import {
faAward, faAward,
faLink, faLink,
faChevronDown, faChevronDown,
faChevronUp,
faSignOutAlt, faSignOutAlt,
faPen, faPen,
faExternalLinkSquareAlt, faExternalLinkSquareAlt,
@@ -90,6 +91,7 @@ library.add(
faAward, faAward,
faLink, faLink,
faChevronDown, faChevronDown,
faChevronUp,
faSignOutAlt, faSignOutAlt,
faPen, faPen,
faExternalLinkSquareAlt, faExternalLinkSquareAlt,

View File

@@ -133,6 +133,11 @@
"Network name...": "Network name...", "Network name...": "Network name...",
"Select a network...": "Select a network...", "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.", "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", "Console is not enabled": "Console is not enabled",
"ConsoleNotEnabledMSG1": "Console is a powerful tool that allows you to execute any commands such as <code>docker</code>, <code>rm</code> within the Dockge's container in this Web UI.", "ConsoleNotEnabledMSG1": "Console is a powerful tool that allows you to execute any commands such as <code>docker</code>, <code>rm</code> 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 <code>rm -rf</code>" , "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 <code>rm -rf</code>" ,

View File

@@ -128,12 +128,11 @@
:name="name" :name="name"
:is-edit-mode="isEditMode" :is-edit-mode="isEditMode"
:first="name === Object.keys(jsonConfig.services)[0]" :first="name === Object.keys(jsonConfig.services)[0]"
:processing="processing" :serviceStatus="serviceStatusList[name]"
:dockerStats="dockerStats"
@start-service="startService" @start-service="startService"
@stop-service="stopService" @stop-service="stopService"
@restart-service="restartService" @restart-service="restartService"
:status="serviceStatusList[name]?.state"
:ports="serviceStatusList[name]?.ports"
/> />
</div> </div>
@@ -282,6 +281,12 @@ const envDefault = "# VARIABLE=value #comment";
let yamlErrorTimeout = null; let yamlErrorTimeout = null;
let serviceStatusTimeout = null; let serviceStatusTimeout = null;
let dockerStatsTimeout = null;
let prismjsSymbolDefinition = {
"symbol": {
pattern: /(?<!\$)\$(\{[^{}]*\}|\w+)/,
}
};
export default { export default {
components: { components: {
@@ -337,11 +342,13 @@ export default {
}, },
serviceStatusList: {}, serviceStatusList: {},
dockerStats: {},
isEditMode: false, isEditMode: false,
submitted: false, submitted: false,
showDeleteDialog: false, showDeleteDialog: false,
newContainerName: "", newContainerName: "",
stopServiceStatusTimeout: false, stopServiceStatusTimeout: false,
stopDockerStatsTimeout: false,
}; };
}, },
computed: { computed: {
@@ -508,6 +515,7 @@ export default {
} }
this.requestServiceStatus(); this.requestServiceStatus();
this.requestDockerStats();
}, },
unmounted() { unmounted() {
@@ -520,6 +528,13 @@ export default {
}, 5000); }, 5000);
}, },
startDockerStatsTimeout() {
clearTimeout(dockerStatsTimeout);
dockerStatsTimeout = setTimeout(async () => {
this.requestDockerStats();
}, 5000);
},
requestServiceStatus() { requestServiceStatus() {
// Do not request if it is add mode // Do not request if it is add mode
if (this.isAdd) { 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) { exitConfirm(next) {
if (this.isEditMode) { if (this.isEditMode) {
if (confirm(this.$t("confirmLeaveStack"))) { if (confirm(this.$t("confirmLeaveStack"))) {
@@ -553,7 +579,9 @@ export default {
exitAction() { exitAction() {
console.log("exitAction"); console.log("exitAction");
this.stopServiceStatusTimeout = true; this.stopServiceStatusTimeout = true;
this.stopDockerStatsTimeout = true;
clearTimeout(serviceStatusTimeout); clearTimeout(serviceStatusTimeout);
clearTimeout(dockerStatsTimeout);
// Leave Combined Terminal // Leave Combined Terminal
console.debug("leaveCombinedTerminal", this.endpoint, this.stack.name); console.debug("leaveCombinedTerminal", this.endpoint, this.stack.name);