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

View File

@@ -637,6 +637,35 @@ export class DockgeServer {
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() {
return path.resolve(this.stacksDir);
}

View File

@@ -508,7 +508,7 @@ export class Stack {
}
async getServiceStatusList() {
let statusList = new Map<string, { state: string, ports: string[] }>();
let statusList = new Map<string, Array<object>>();
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) {

View File

@@ -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']

View File

@@ -58,6 +58,32 @@
{{ $t("deleteContainer") }}
</button>
</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>
<div v-if="isEditMode && showConfig" class="config mt-3">
@@ -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;
}
}
</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,
faLink,
faChevronDown,
faChevronUp,
faSignOutAlt,
faPen,
faExternalLinkSquareAlt,
@@ -90,6 +91,7 @@ library.add(
faAward,
faLink,
faChevronDown,
faChevronUp,
faSignOutAlt,
faPen,
faExternalLinkSquareAlt,

View File

@@ -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 <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>" ,

View File

@@ -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"
/>
</div>
@@ -282,6 +281,12 @@ const envDefault = "# VARIABLE=value #comment";
let yamlErrorTimeout = null;
let serviceStatusTimeout = null;
let dockerStatsTimeout = null;
let prismjsSymbolDefinition = {
"symbol": {
pattern: /(?<!\$)\$(\{[^{}]*\}|\w+)/,
}
};
export default {
components: {
@@ -337,11 +342,13 @@ export default {
},
serviceStatusList: {},
dockerStats: {},
isEditMode: false,
submitted: false,
showDeleteDialog: false,
newContainerName: "",
stopServiceStatusTimeout: false,
stopDockerStatsTimeout: false,
};
},
computed: {
@@ -508,6 +515,7 @@ export default {
}
this.requestServiceStatus();
this.requestDockerStats();
},
unmounted() {
@@ -520,6 +528,13 @@ export default {
}, 5000);
},
startDockerStatsTimeout() {
clearTimeout(dockerStatsTimeout);
dockerStatsTimeout = setTimeout(async () => {
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);