Repository: amir20/dozzle (language: go)

Issue:
Live stats - show limits and reservations
### Describe the feature you would like to see

First of all, I love Dozzle and the work done is awesome!

I'm using it in a docker swarm env, I would love to have info about CPU and RAM limits and/or reservations in the UI.

From `docker inspect`, it would be great to pickup those info 

```json

            "Memory": 15032385536,
            "NanoCpus": 3500000000,
```

```json
        "MemoryReservation": 0,
```

```json
            "CpuCount": 0,
            "CpuPercent": 0,
```



### Describe how you would like to see this feature implemented

From the service view, we could have it in the right top corner:
![Image](https://github.com/user-attachments/assets/2cfe8926-cf29-4a70-b22c-4dab49c077cb)

e.g.:
MEM 1.0GB / 3.0GB

LOAD 103% / 3.5 CPUs

From the container list view:

![Image](https://github.com/user-attachments/assets/7c4459d4-4cdf-4569-a631-29db30780787)

This can be displayed too, so we know how many percent of how much CPUs and RAM

### Describe any alternatives you've considered

_No response_


Discussion / hints:
I have always struggled where to put this data. I actually like the idea of XX / YY. 

I won't be working on this for a while, but will leave it here until I or someone else gets to it. 
I currently have this: 

<img width="422" alt="Image" src="https://github.com/user-attachments/assets/42178953-68e4-496f-b25c-51cc22bced85" />

I am not super happy with it. There isn't a lot of space. So I might try: 

1. Try making the stats 50px wider 
2. Change colors 
3. Or, try a different layout. Although, I am not too sure what it would be.

Thoughts? 

Fix patch:
diff --git a/assets/components/FuzzySearchModal.spec.ts b/assets/components/FuzzySearchModal.spec.ts
index 80b90e903510..d56846fd31d3 100644
--- a/assets/components/FuzzySearchModal.spec.ts
+++ b/assets/components/FuzzySearchModal.spec.ts
@@ -42,6 +42,8 @@ function createFuzzySearchModal() {
                   "host",
                   {},
                   "running",
+                  0,
+                  0,
                   [],
                 ),
                 new Container(
@@ -55,6 +57,8 @@ function createFuzzySearchModal() {
                   "host",
                   {},
                   "running",
+                  0,
+                  0,
                   [],
                 ),
                 new Container(
@@ -68,6 +72,8 @@ function createFuzzySearchModal() {
                   "host",
                   {},
                   "running",
+                  0,
+                  0,
                   [],
                 ),
               ],
diff --git a/assets/components/HostList.vue b/assets/components/HostList.vue
index e773789726d2..df6449902e09 100644
--- a/assets/components/HostList.vue
+++ b/assets/components/HostList.vue
@@ -51,7 +51,7 @@
             :style="`--value: ${Math.floor((weightedStats[host.id].weighted.totalMem / host.memTotal) * 100)};`"
             role="progressbar"
           >
-            {{ formatBytes(weightedStats[host.id].weighted.totalMem, 1) }}
+            {{ formatBytes(weightedStats[host.id].weighted.totalMem, { decimals: 1, short: true }) }}
           </div>
         </div>
       </div>
diff --git a/assets/components/LogViewer/EventSource.spec.ts b/assets/components/LogViewer/EventSource.spec.ts
index 47d4b31b1125..15d7d074a437 100644
--- a/assets/components/LogViewer/EventSource.spec.ts
+++ b/assets/components/LogViewer/EventSource.spec.ts
@@ -105,6 +105,8 @@ describe("<ContainerEventSource />", () => {
           "localhost",
           {},
           "created",
+          0,
+          0,
           [],
         ),
       },
diff --git a/assets/components/LogViewer/MultiContainerStat.vue b/assets/components/LogViewer/MultiContainerStat.vue
index 30f25ff51215..9e463113ebf7 100644
--- a/assets/components/LogViewer/MultiContainerStat.vue
+++ b/assets/components/LogViewer/MultiContainerStat.vue
@@ -1,7 +1,17 @@
 <template>
   <div class="flex gap-4">
-    <StatMonitor :data="memoryData" label="mem" :stat-value="formatBytes(totalStat.memoryUsage)" />
-    <StatMonitor :data="cpuData" label="load" :stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'" />
+    <StatMonitor
+      :data="memoryData"
+      label="mem"
+      :stat-value="formatBytes(totalStat.memoryUsage)"
+      :limit="formatBytes(limits.memory, { short: true, decimals: 1 })"
+    />
+    <StatMonitor
+      :data="cpuData"
+      label="load"
+      :stat-value="Math.max(0, totalStat.cpu).toFixed(2) + '%'"
+      :limit="limits.cpu.toFixed(0) + ' CPUs'"
+    />
   </div>
 </template>
 
@@ -15,6 +25,7 @@ const { containers } = defineProps<{
 
 const totalStat = ref<Stat>({ cpu: 0, memory: 0, memoryUsage: 0 });
 const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
+const { hosts } = useHosts();
 
 watch(
   () => containers,
@@ -42,6 +53,19 @@ watch(
   { immediate: true },
 );
 
+const limits = computed(() => {
+  const limit = containers.reduce(
+    (acc, c) => {
+      return {
+        cpu: acc.cpu + c.cpuLimit > 0 ? c.cpuLimit : hosts.value[c.host].nCPU,
+        memory: acc.memory + c.memoryLimit > 0 ? c.memoryLimit : hosts.value[c.host].memTotal,
+      };
+    },
+    { cpu: 0, memory: 0 },
+  );
+  return limit;
+});
+
 useIntervalFn(() => {
   totalStat.value = containers.reduce(
     (acc, { stat }) => {
diff --git a/assets/components/LogViewer/StatMonitor.vue b/assets/components/LogViewer/StatMonitor.vue
index 76b0ebbe6ab3..9bc5c39c13c2 100644
--- a/assets/components/LogViewer/StatMonitor.vue
+++ b/assets/components/LogViewer/StatMonitor.vue
@@ -1,25 +1,31 @@
 <template>
   <div class="hover:text-secondary relative" @mouseenter="mouseOver = true" @mouseleave="mouseOver = false">
-    <div class="border-primary hidden overflow-hidden rounded-xs border px-px pt-1 pb-px md:flex">
+    <div class="border-primary overflow-hidden rounded-xs border px-px pt-1 pb-px max-md:hidden">
       <StatSparkline :data="data" @selected-point="onSelectedPoint" />
     </div>
     <div class="bg-base-200 inline-flex gap-1 rounded-sm p-px text-xs md:absolute md:-top-2 md:-left-0.5">
       <div class="font-light uppercase">{{ label }}</div>
       <div class="font-bold select-none">
         {{ mouseOver ? (selectedPoint?.value ?? selectedPoint?.y ?? statValue) : statValue }}
+        <span v-if="limit !== -1 && !mouseOver" class="max-md:hidden"> / {{ limit }} </span>
       </div>
     </div>
   </div>
 </template>
 
 <script lang="ts" setup>
-const { data, label, statValue } = defineProps<{ data: Point<unknown>[]; label: string; statValue: string | number }>();
-
-let selectedPoint: Point<unknown> | undefined = $ref();
-
-function onSelectedPoint(point: Point<unknown>) {
-  selectedPoint = point;
-}
-
-let mouseOver = $ref(false);
+const {
+  data,
+  label,
+  statValue,
+  limit = -1,
+} = defineProps<{
+  data: Point<unknown>[];
+  label: string;
+  statValue: string | number;
+  limit?: string | number;
+}>();
+const selectedPoint = ref<Point<unknown> | undefined>();
+const onSelectedPoint = (point: Point<unknown>) => (selectedPoint.value = point);
+const mouseOver = ref(false);
 </script>
diff --git a/assets/components/LogViewer/StatSparkline.vue b/assets/components/LogViewer/StatSparkline.vue
index e7ed0c1e8849..7cf8d15adfb0 100644
--- a/assets/components/LogViewer/StatSparkline.vue
+++ b/assets/components/LogViewer/StatSparkline.vue
@@ -11,7 +11,7 @@ import { scaleLinear } from "d3-scale";
 import { area, curveStep } from "d3-shape";
 
 const d3 = { extent, scaleLinear, area, curveStep };
-const { data, width = 150, height = 30 } = defineProps<{ data: Point<unknown>[]; width?: number; height?: number }>();
+const { data, width = 175, height = 30 } = defineProps<{ data: Point<unknown>[]; width?: number; height?: number }>();
 const x = d3.scaleLinear().range([0, width]);
 const y = d3.scaleLinear().range([height, 0]);
 
diff --git a/assets/models/Container.ts b/assets/models/Container.ts
index ff701563bb29..73d35c8b3e39 100644
--- a/assets/models/Container.ts
+++ b/assets/models/Container.ts
@@ -38,6 +38,8 @@ export class Container {
     public readonly host: string,
     public readonly labels = {} as Record<string, string>,
     public state: ContainerState,
+    public readonly cpuLimit: number,
+    public readonly memoryLimit: number,
     stats: Stat[],
     public readonly group?: string,
     public health?: ContainerHealth,
diff --git a/assets/stores/container.ts b/assets/stores/container.ts
index 10ddee357a99..8c7e97d09a17 100644
--- a/assets/stores/container.ts
+++ b/assets/stores/container.ts
@@ -154,6 +154,8 @@ export const useContainerStore = defineStore("container", () => {
           c.host,
           c.labels,
           c.state,
+          c.cpuLimit,
+          c.memoryLimit,
           c.stats,
           c.group,
           c.health,
diff --git a/assets/types/Container.d.ts b/assets/types/Container.d.ts
index 4639e69aaad0..9d6ddb5e5a7e 100644
--- a/assets/types/Container.d.ts
+++ b/assets/types/Container.d.ts
@@ -16,6 +16,8 @@ export type ContainerJson = {
   readonly status: string;
   readonly state: ContainerState;
   readonly host: string;
+  readonly cpuLimit: number;
+  readonly memoryLimit: number;
   readonly labels: Record<string, string>;
   readonly stats: ContainerStat[];
   readonly health?: ContainerHealth;
diff --git a/assets/utils/index.ts b/assets/utils/index.ts
index b9c328ca3885..70e1753ba7cf 100644
--- a/assets/utils/index.ts
+++ b/assets/utils/index.ts
@@ -1,10 +1,19 @@
-export function formatBytes(bytes: number, decimals = 2) {
+export function formatBytes(
+  bytes: number,
+  { decimals = 2, short = false }: { decimals?: number; short?: boolean } = { decimals: 2, short: false },
+) {
   if (bytes === 0) return "0 Bytes";
   const k = 1024;
   const dm = decimals < 0 ? 0 : decimals;
   const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
   const i = Math.floor(Math.log(bytes) / Math.log(k));
-  return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
+
+  const value = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
+  if (short) {
+    return value + sizes[i].charAt(0);
+  } else {
+    return value + " " + sizes[i];
+  }
 }
 
 export function getDeep(obj: Record<string, any>, path: string[]) {
diff --git a/internal/container/types.go b/internal/container/types.go
index 8f94e31a7a94..afefe2882d6f 100644
--- a/internal/container/types.go
+++ b/internal/container/types.go
@@ -24,6 +24,8 @@ type Container struct {
 	Tty         bool                             `json:"-"`
 	Labels      map[string]string                `json:"labels,omitempty"`
 	Stats       *utils.RingBuffer[ContainerStat] `json:"stats,omitempty"`
+	MemoryLimit int64                            `json:"memoryLimit"`
+	CPULimit    float64                          `json:"cpuLimit"`
 	Group       string                           `json:"group,omitempty"`
 	FullyLoaded bool                             `json:"-,omitempty"`
 }
@@ -54,7 +56,7 @@ func ParseContainerFilter(commaValues string) (ContainerLabels, error) {
 		return filter, nil
 	}
 
-	for _, val := range strings.Split(commaValues, ",") {
+	for val := range strings.SplitSeq(commaValues, ",") {
 		pos := strings.Index(val, "=")
 		if pos == -1 {
 			return nil, fmt.Errorf("invalid filter: %s", filter)
diff --git a/internal/docker/client.go b/internal/docker/client.go
index 2dac4b4a59bd..f27b76bcaf25 100644
--- a/internal/docker/client.go
+++ b/internal/docker/client.go
@@ -388,6 +388,11 @@ func newContainerFromJSON(c docker.InspectResponse, host string) container.Conta
 		group = c.Config.Labels["dev.dozzle.group"]
 	}
 
+	CPULimit := float64(0)
+	if c.HostConfig.CPUPeriod > 0 {
+		CPULimit = float64(c.HostConfig.CPUQuota) / float64(c.HostConfig.CPUPeriod)
+	}
+
 	container := container.Container{
 		ID:          c.ID[:12],
 		Name:        name,
@@ -399,6 +404,8 @@ func newContainerFromJSON(c docker.InspectResponse, host string) container.Conta
 		Stats:       utils.NewRingBuffer[container.ContainerStat](300), // 300 seconds of stats
 		Group:       group,
 		Tty:         c.Config.Tty,
+		MemoryLimit: c.HostConfig.Memory,
+		CPULimit:    CPULimit,
 		FullyLoaded: true,
 	}
 


Test patch:
diff --git a/internal/docker/client_test.go b/internal/docker/client_test.go
index 36c8b221d78b..70c14d082b04 100644
--- a/internal/docker/client_test.go
+++ b/internal/docker/client_test.go
@@ -184,7 +184,11 @@ func Test_dockerClient_FindContainer_happy(t *testing.T) {
 	proxy := new(mockedProxy)
 
 	state := &docker.State{Status: "running", StartedAt: time.Now().Format(time.RFC3339Nano)}
-	json := docker.InspectResponse{ContainerJSONBase: &docker.ContainerJSONBase{ID: "abcdefghijklmnopqrst", State: state}, Config: &docker.Config{Tty: false}}
+
+	json := docker.InspectResponse{
+		ContainerJSONBase: &docker.ContainerJSONBase{ID: "abcdefghijklmnopqrst", State: state, HostConfig: &docker.HostConfig{}},
+		Config:            &docker.Config{Tty: false},
+	}
 	proxy.On("ContainerInspect", mock.Anything, "abcdefghijkl").Return(json, nil)
 
 	client := &DockerClient{proxy, container.Host{ID: "localhost"}, system.Info{}}
@@ -213,7 +217,7 @@ func Test_dockerClient_ContainerActions_happy(t *testing.T) {
 	client := &DockerClient{proxy, container.Host{ID: "localhost"}, system.Info{}}
 
 	state := &docker.State{Status: "running", StartedAt: time.Now().Format(time.RFC3339Nano)}
-	json := docker.InspectResponse{ContainerJSONBase: &docker.ContainerJSONBase{ID: "abcdefghijkl", State: state}, Config: &docker.Config{Tty: false}}
+	json := docker.InspectResponse{ContainerJSONBase: &docker.ContainerJSONBase{ID: "abcdefghijkl", State: state, HostConfig: &docker.HostConfig{}}, Config: &docker.Config{Tty: false}}
 
 	proxy.On("ContainerInspect", mock.Anything, "abcdefghijkl").Return(json, nil)
 	proxy.On("ContainerStart", mock.Anything, "abcdefghijkl", mock.Anything).Return(nil)