1
0
mirror of https://github.com/amir20/dozzle.git synced 2025-12-21 21:33:18 +01:00

feat: Shows hosts in cards with their respective stats and updates the container table to filter by host 🥳 (#2932)

This commit is contained in:
Amir Raminfar
2024-05-10 14:35:17 -07:00
committed by GitHub
parent dcb5eb4ccc
commit 44f68cc482
9 changed files with 177 additions and 72 deletions

View File

@@ -38,6 +38,7 @@ declare module 'vue' {
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default'] DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
FieldList: typeof import('./components/LogViewer/FieldList.vue')['default'] FieldList: typeof import('./components/LogViewer/FieldList.vue')['default']
FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default'] FuzzySearchModal: typeof import('./components/FuzzySearchModal.vue')['default']
HostList: typeof import('./components/HostList.vue')['default']
'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default'] 'Ic:sharpKeyboardReturn': typeof import('~icons/ic/sharp-keyboard-return')['default']
InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default'] InfiniteLoader: typeof import('./components/InfiniteLoader.vue')['default']
KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default'] KeyShortcut: typeof import('./components/common/KeyShortcut.vue')['default']
@@ -73,6 +74,8 @@ declare module 'vue' {
'Ph:command': typeof import('~icons/ph/command')['default'] 'Ph:command': typeof import('~icons/ph/command')['default']
'Ph:computerTower': typeof import('~icons/ph/computer-tower')['default'] 'Ph:computerTower': typeof import('~icons/ph/computer-tower')['default']
'Ph:controlBold': typeof import('~icons/ph/control-bold')['default'] 'Ph:controlBold': typeof import('~icons/ph/control-bold')['default']
'Ph:cpu': typeof import('~icons/ph/cpu')['default']
'Ph:memory': typeof import('~icons/ph/memory')['default']
Popup: typeof import('./components/Popup.vue')['default'] Popup: typeof import('./components/Popup.vue')['default']
Releases: typeof import('./components/Releases.vue')['default'] Releases: typeof import('./components/Releases.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink'] RouterLink: typeof import('vue-router')['RouterLink']

View File

@@ -1,13 +1,49 @@
! !
<template> <template>
<div class="text-right" v-if="containers.length > pageSizes[0]"> <div class="flex flex-row">
Show per page <div v-if="Object.keys(hosts).length > 1" class="flex-1">
<dropdown-menu <div role="tablist" class="tabs-boxed tabs block" v-if="Object.keys(hosts).length < 4">
class="dropdown-left btn-xs md:btn-sm" <input
v-model="perPage" type="radio"
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))" name="host"
/> role="tab"
class="tab !rounded"
aria-label="Show All"
v-model="selectedHost"
:value="null"
/>
<input
type="radio"
name="host"
role="tab"
class="tab !rounded"
:aria-label="host.name"
v-for="host in hosts"
:value="host.id"
:key="host.id"
v-model="selectedHost"
/>
</div>
<dropdown-menu
class="btn-sm"
v-model="selectedHost"
:options="[
{ label: 'Show All', value: null },
...Object.values(hosts).map((host) => ({ label: host.name, value: host.id })),
]"
v-else
/>
</div>
<div class="flex-1 text-right" v-show="containers.length > pageSizes[0]">
{{ $t("label.per-page") }}
<dropdown-menu
class="dropdown-left btn-xs md:btn-sm"
v-model="perPage"
:options="pageSizes.map((i) => ({ label: i.toLocaleString(), value: i }))"
/>
</div>
</div> </div>
<table class="table table-lg bg-base"> <table class="table table-lg bg-base">
<thead> <thead>
<tr :data-direction="direction > 0 ? 'asc' : 'desc'"> <tr :data-direction="direction > 0 ? 'asc' : 'desc'">
@@ -40,7 +76,11 @@
<distance-time :date="container.created" strict :suffix="false"></distance-time> <distance-time :date="container.created" strict :suffix="false"></distance-time>
</td> </td>
<td v-if="isVisible('cpu')"> <td v-if="isVisible('cpu')">
<progress class="progress progress-primary" :value="container.movingAverage.cpu" max="100"></progress> <progress
class="progress progress-primary"
:value="container.movingAverage.cpu"
:max="100 * hosts[container.host].nCPU"
></progress>
</td> </td>
<td v-if="isVisible('mem')"> <td v-if="isVisible('mem')">
<progress class="progress progress-primary" :value="container.movingAverage.memory" max="100"></progress> <progress class="progress progress-primary" :value="container.movingAverage.memory" max="100"></progress>
@@ -50,14 +90,14 @@
</table> </table>
<div class="p-4 text-center"> <div class="p-4 text-center">
<nav class="join" v-if="isPaginated"> <nav class="join" v-if="isPaginated">
<button <input
class="btn btn-square join-item"
type="radio"
v-model="currentPage"
:aria-label="`${i}`"
:value="i"
v-for="i in totalPages" v-for="i in totalPages"
class="btn join-item" />
:class="{ 'btn-primary': i === currentPage }"
@click="currentPage = i"
>
{{ i }}
</button>
</nav> </nav>
</div> </div>
</template> </template>
@@ -66,6 +106,9 @@
import { Container } from "@/models/Container"; import { Container } from "@/models/Container";
import { toRefs } from "@vueuse/core"; import { toRefs } from "@vueuse/core";
const { hosts } = useHosts();
const selectedHost = ref(null);
const fields = { const fields = {
name: { name: {
label: "label.container-name", label: "label.container-name",
@@ -113,9 +156,12 @@ const storage = useStorage<{ column: keys; direction: 1 | -1 }>("DOZZLE_TABLE_CO
}); });
const { column: sortField, direction } = toRefs(storage); const { column: sortField, direction } = toRefs(storage);
const counter = useInterval(10000); const counter = useInterval(10000);
const filteredContainers = computed(() =>
containers.filter((c) => selectedHost.value === null || c.host === selectedHost.value),
);
const sortedContainers = computedWithControl( const sortedContainers = computedWithControl(
() => [containers.length, sortField.value, direction.value, counter.value], () => [filteredContainers.value.length, sortField.value, direction.value, counter.value],
() => containers.sort((a, b) => fields[sortField.value].sortFunc(a, b)), () => filteredContainers.value.sort((a, b) => fields[sortField.value].sortFunc(a, b)),
); );
const totalPages = computed(() => Math.ceil(sortedContainers.value.length / perPage.value)); const totalPages = computed(() => Math.ceil(sortedContainers.value.length / perPage.value));

View File

@@ -0,0 +1,94 @@
<template>
<ul class="grid gap-4 md:grid-cols-[repeat(auto-fill,minmax(480px,1fr))]">
<li v-for="host in hostSummaries" class="card bg-base-lighter">
<div class="card-body grid auto-cols-auto grid-flow-col justify-between">
<div class="overflow-hidden">
<div class="truncate text-xl font-semibold">{{ host.name }}</div>
<ul class="flex flex-row gap-4 text-sm md:gap-3">
<li><ph:cpu class="inline-block" /> {{ host.nCPU }} <span class="mobile-hidden">CPUs</span></li>
<li>
<ph:memory class="inline-block" /> {{ formatBytes(host.memTotal) }}
<span class="mobile-hidden">total</span>
</li>
</ul>
<div class="text-sm">
<octicon:container-24 class="inline-block" /> {{ $t("label.container", host.containers.length) }}
</div>
</div>
<div class="flex flex-row gap-8">
<div
class="radial-progress text-primary"
:style="`--value: ${Math.floor((host.totalCPU / (host.nCPU * 100)) * 100)}; --thickness: 0.25em`"
role="progressbar"
>
{{ host.totalCPU.toFixed(0) }}%
</div>
<div
class="radial-progress text-primary"
:style="`--value: ${(host.totalMem / host.memTotal) * 100}; --thickness: 0.25em`"
role="progressbar"
>
{{ formatBytes(host.totalMem, 1) }}
</div>
</div>
</div>
</li>
</ul>
</template>
<script setup lang="ts">
import { Container } from "@/models/Container";
const { containers } = defineProps<{
containers: Container[];
}>();
const { hosts } = useHosts();
type HostSummary = {
name: string;
containers: Container[];
totalCPU: number;
totalMem: number;
nCPU: number;
memTotal: number;
};
const hostSummaries = computed(() => {
const summaries: Record<string, HostSummary> = {};
for (const container of containers) {
if (!summaries[container.host]) {
const host = hosts.value[container.host];
summaries[container.host] = reactive({
name: host.name,
containers: [],
totalCPU: 0,
totalMem: 0,
nCPU: host.nCPU,
memTotal: host.memTotal,
});
}
const summary = summaries[container.host];
summary.containers.push(container);
}
return Object.values(summaries).sort((a, b) => a.name.localeCompare(b.name));
});
useIntervalFn(
() => {
for (const summary of hostSummaries.value) {
summary.totalCPU = 0;
summary.totalMem = 0;
for (const container of summary.containers) {
summary.totalCPU += container.stat.cpu;
summary.totalMem += container.stat.memoryUsage;
}
}
},
1000,
{ immediate: true },
);
</script>
<style scoped></style>

View File

@@ -1,25 +1,7 @@
<template> <template>
<page-with-links class="gap-16"> <page-with-links>
<section> <section>
<div class="stats grid bg-base-lighter shadow"> <host-list :containers="runningContainers" />
<div class="stat">
<div class="stat-value">{{ runningContainers.length }} / {{ containers.length }}</div>
<div class="stat-title">{{ $t("label.running") }} / {{ $t("label.total-containers") }}</div>
</div>
<div class="stat">
<div class="stat-value">{{ totalCpu.toFixed(0) }}%</div>
<div class="stat-title">{{ $t("label.total-cpu-usage") }}</div>
</div>
<div class="stat">
<div class="stat-value">{{ formatBytes(totalMem) }}</div>
<div class="stat-title">{{ $t("label.total-mem-usage") }}</div>
</div>
<div class="stat">
<div class="stat-value">{{ version }}</div>
<div class="stat-title">{{ $t("label.dozzle-version") }}</div>
</div>
</div>
</section> </section>
<section> <section>
@@ -32,37 +14,18 @@
import { Container } from "@/models/Container"; import { Container } from "@/models/Container";
const { t } = useI18n(); const { t } = useI18n();
const { version } = config;
const containerStore = useContainerStore(); const containerStore = useContainerStore();
const { containers, ready } = storeToRefs(containerStore) as unknown as { const { containers, ready } = storeToRefs(containerStore) as unknown as {
containers: Ref<Container[]>; containers: Ref<Container[]>;
ready: Ref<boolean>; ready: Ref<boolean>;
}; };
const mostRecentContainers = $computed(() => [...containers.value].sort((a, b) => +b.created - +a.created)); const runningContainers = computed(() => containers.value.filter((c) => c.state === "running"));
const runningContainers = $computed(() => mostRecentContainers.filter((c) => c.state === "running"));
let totalCpu = $ref(0);
useIntervalFn(
() => {
totalCpu = runningContainers.reduce((acc, c) => acc + c.stat.cpu, 0);
},
1000,
{ immediate: true },
);
let totalMem = $ref(0);
useIntervalFn(
() => {
totalMem = runningContainers.reduce((acc, c) => acc + c.stat.memoryUsage, 0);
},
1000,
{ immediate: true },
);
watchEffect(() => { watchEffect(() => {
if (ready.value) { if (ready.value) {
setTitle(t("title.dashboard", { count: runningContainers.length })); setTitle(t("title.dashboard", { count: runningContainers.value.length }));
} }
}); });
</script> </script>

View File

@@ -17,7 +17,7 @@
</div> </div>
<div class="form-control"> <div class="form-control">
<label class="label"> <label class="label">
<span class="label-text">Password</span> <span class="label-text">{{ $t("label.password") }}</span>
</label> </label>
<input <input
class="input input-bordered" class="input input-bordered"

View File

@@ -1,13 +1,16 @@
import { type Settings } from "@/stores/settings"; import { type Settings } from "@/stores/settings";
import { Host } from "@/stores/hosts";
const text = document.querySelector("script#config__json")?.textContent || "{}"; const text = document.querySelector("script#config__json")?.textContent || "{}";
type HostWithoutAvailable = Omit<Host, "available">;
export interface Config { export interface Config {
version: string; version: string;
base: string; base: string;
maxLogs: number; maxLogs: number;
hostname: string; hostname: string;
hosts: { name: string; id: string }[]; hosts: HostWithoutAvailable[];
authProvider: "simple" | "none" | "forward-proxy"; authProvider: "simple" | "none" | "forward-proxy";
enableActions: boolean; enableActions: boolean;
user?: { user?: {

View File

@@ -1,6 +1,8 @@
type Host = { export type Host = {
name: string; name: string;
id: string; id: string;
nCPU: number;
memTotal: number;
available: boolean; available: boolean;
}; };
const hosts = computed(() => const hosts = computed(() =>

View File

@@ -9,10 +9,7 @@ test("has right title", async ({ page }) => {
}); });
test("has dashboard text", async ({ page }) => { test("has dashboard text", async ({ page }) => {
await expect(page.getByText("Total Containers")).toBeVisible(); await expect(page.getByText("container name")).toBeVisible();
await expect(page.getByText("Total CPU Usage")).toBeVisible();
await expect(page.getByText("Total Mem Usage")).toBeVisible();
await expect(page.getByText("Dozzle Version")).toBeVisible();
}); });
test("click on settings button", async ({ page }) => { test("click on settings button", async ({ page }) => {

View File

@@ -9,14 +9,9 @@ toolbar:
restart: Restart restart: Restart
label: label:
containers: Containers containers: Containers
container: No containers | 1 container | {count} containers
running-containers: Running Containers running-containers: Running Containers
all-containers: All Containers all-containers: All Containers
total-containers: Total Containers
running: Running
total-cpu-usage: Total CPU Usage
total-mem-usage: Total Mem Usage
dozzle-version: Dozzle Version
all: All
host: Host host: Host
password: Password password: Password
username: Username username: Username
@@ -26,6 +21,8 @@ label:
avg-cpu: Avg. CPU (%) avg-cpu: Avg. CPU (%)
avg-mem: Avg. MEM (%) avg-mem: Avg. MEM (%)
pinned: Pinned pinned: Pinned
per-page: Rows per page
tooltip: tooltip:
search: Search containers (⌘ + k, ⌃k) search: Search containers (⌘ + k, ⌃k)
pin-column: Pin as column pin-column: Pin as column