Add resource usage stats to the compose page

* Also fix bug where container status wasn't showing when `docker compose ps` returned an array
This commit is contained in:
Justin Wiebe 2025-01-02 20:30:58 -06:00
parent a65a9f5549
commit ba868b6279
9 changed files with 244 additions and 10 deletions

View file

@ -238,6 +238,21 @@ 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);
} catch (e) {
callbackError(e, callback);
}
});
// getExternalNetworkList // getExternalNetworkList
agentSocket.on("getDockerNetworkList", async (callback) => { agentSocket.on("getDockerNetworkList", async (callback) => {
try { try {

View file

@ -631,6 +631,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

@ -497,7 +497,7 @@ export class Stack {
} }
async getServiceStatusList() { async getServiceStatusList() {
let statusList = new Map<string, number>(); let statusList = new Map<string, Array<object>>();
try { try {
let res = await childProcessAsync.spawn("docker", [ "compose", "ps", "--format", "json" ], { let res = await childProcessAsync.spawn("docker", [ "compose", "ps", "--format", "json" ], {
@ -511,13 +511,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);
if (obj.Health === "") { if (obj instanceof Array) {
statusList.set(obj.Service, obj.State); obj.forEach(addLine);
} else { } else {
statusList.set(obj.Service, obj.Health); addLine(obj);
} }
} catch (e) { } catch (e) {
} }
@ -528,6 +538,5 @@ export class Stack {
log.error("getServiceStatusList", e); log.error("getServiceStatusList", e);
return statusList; return statusList;
} }
} }
} }

View file

@ -16,6 +16,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']
HiddenInput: typeof import('./src/components/HiddenInput.vue')['default'] HiddenInput: typeof import('./src/components/HiddenInput.vue')['default']
Login: typeof import('./src/components/Login.vue')['default'] Login: typeof import('./src/components/Login.vue')['default']

View file

@ -35,6 +35,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">
@ -138,10 +164,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: {
@ -156,9 +184,13 @@ export default defineComponent({
type: Boolean, type: Boolean,
default: false, default: false,
}, },
status: { serviceStatus: {
type: String, type: Object,
default: "N/A", default: null,
},
dockerStats: {
type: Object,
default: null
} }
}, },
emits: [ emits: [
@ -166,6 +198,7 @@ export default defineComponent({
data() { data() {
return { return {
showConfig: false, showConfig: false,
expandedStats: false,
}; };
}, },
computed: { computed: {
@ -266,6 +299,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) {
@ -308,5 +357,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,
@ -88,6 +89,7 @@ library.add(
faAward, faAward,
faLink, faLink,
faChevronDown, faChevronDown,
faChevronUp,
faSignOutAlt, faSignOutAlt,
faPen, faPen,
faExternalLinkSquareAlt, faExternalLinkSquareAlt,

View file

@ -128,5 +128,10 @@
"New Container Name...": "New Container Name...", "New Container Name...": "New Container Name...",
"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"
} }

View file

@ -128,7 +128,8 @@
: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]"
:status="serviceStatusList[name]" :serviceStatus="serviceStatusList[name]"
:dockerStats="dockerStats"
/> />
</div> </div>
@ -271,6 +272,7 @@ const envDefault = "# VARIABLE=value #comment";
let yamlErrorTimeout = null; let yamlErrorTimeout = null;
let serviceStatusTimeout = null; let serviceStatusTimeout = null;
let dockerStatsTimeout = null;
let prismjsSymbolDefinition = { let prismjsSymbolDefinition = {
"symbol": { "symbol": {
pattern: /(?<!\$)\$(\{[^{}]*\}|\w+)/, pattern: /(?<!\$)\$(\{[^{}]*\}|\w+)/,
@ -306,11 +308,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: {
@ -478,6 +482,7 @@ export default {
} }
this.requestServiceStatus(); this.requestServiceStatus();
this.requestDockerStats();
}, },
unmounted() { unmounted() {
@ -490,6 +495,13 @@ export default {
}, 5000); }, 5000);
}, },
startDockerStatsTimeout() {
clearTimeout(dockerStatsTimeout);
dockerStatsTimeout = setTimeout(async () => {
this.requestDockerStats();
}, 5000);
},
requestServiceStatus() { requestServiceStatus() {
this.$root.emitAgent(this.endpoint, "serviceStatusList", this.stack.name, (res) => { this.$root.emitAgent(this.endpoint, "serviceStatusList", this.stack.name, (res) => {
if (res.ok) { if (res.ok) {
@ -501,6 +513,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("You are currently editing a stack. Are you sure you want to leave?")) { if (confirm("You are currently editing a stack. Are you sure you want to leave?")) {
@ -518,7 +541,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);