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:
3
assets/components.d.ts
vendored
3
assets/components.d.ts
vendored
@@ -38,6 +38,7 @@ declare module 'vue' {
|
||||
DropdownMenu: typeof import('./components/common/DropdownMenu.vue')['default']
|
||||
FieldList: typeof import('./components/LogViewer/FieldList.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']
|
||||
InfiniteLoader: typeof import('./components/InfiniteLoader.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:computerTower': typeof import('~icons/ph/computer-tower')['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']
|
||||
Releases: typeof import('./components/Releases.vue')['default']
|
||||
RouterLink: typeof import('vue-router')['RouterLink']
|
||||
|
||||
@@ -1,13 +1,49 @@
|
||||
!
|
||||
<template>
|
||||
<div class="text-right" v-if="containers.length > pageSizes[0]">
|
||||
Show per page
|
||||
<div class="flex flex-row">
|
||||
<div v-if="Object.keys(hosts).length > 1" class="flex-1">
|
||||
<div role="tablist" class="tabs-boxed tabs block" v-if="Object.keys(hosts).length < 4">
|
||||
<input
|
||||
type="radio"
|
||||
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>
|
||||
|
||||
<table class="table table-lg bg-base">
|
||||
<thead>
|
||||
<tr :data-direction="direction > 0 ? 'asc' : 'desc'">
|
||||
@@ -40,7 +76,11 @@
|
||||
<distance-time :date="container.created" strict :suffix="false"></distance-time>
|
||||
</td>
|
||||
<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 v-if="isVisible('mem')">
|
||||
<progress class="progress progress-primary" :value="container.movingAverage.memory" max="100"></progress>
|
||||
@@ -50,14 +90,14 @@
|
||||
</table>
|
||||
<div class="p-4 text-center">
|
||||
<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"
|
||||
class="btn join-item"
|
||||
:class="{ 'btn-primary': i === currentPage }"
|
||||
@click="currentPage = i"
|
||||
>
|
||||
{{ i }}
|
||||
</button>
|
||||
/>
|
||||
</nav>
|
||||
</div>
|
||||
</template>
|
||||
@@ -66,6 +106,9 @@
|
||||
import { Container } from "@/models/Container";
|
||||
import { toRefs } from "@vueuse/core";
|
||||
|
||||
const { hosts } = useHosts();
|
||||
const selectedHost = ref(null);
|
||||
|
||||
const fields = {
|
||||
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 counter = useInterval(10000);
|
||||
const filteredContainers = computed(() =>
|
||||
containers.filter((c) => selectedHost.value === null || c.host === selectedHost.value),
|
||||
);
|
||||
const sortedContainers = computedWithControl(
|
||||
() => [containers.length, sortField.value, direction.value, counter.value],
|
||||
() => containers.sort((a, b) => fields[sortField.value].sortFunc(a, b)),
|
||||
() => [filteredContainers.value.length, sortField.value, direction.value, counter.value],
|
||||
() => filteredContainers.value.sort((a, b) => fields[sortField.value].sortFunc(a, b)),
|
||||
);
|
||||
|
||||
const totalPages = computed(() => Math.ceil(sortedContainers.value.length / perPage.value));
|
||||
|
||||
94
assets/components/HostList.vue
Normal file
94
assets/components/HostList.vue
Normal 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>
|
||||
@@ -1,25 +1,7 @@
|
||||
<template>
|
||||
<page-with-links class="gap-16">
|
||||
<page-with-links>
|
||||
<section>
|
||||
<div class="stats grid bg-base-lighter shadow">
|
||||
<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>
|
||||
<host-list :containers="runningContainers" />
|
||||
</section>
|
||||
|
||||
<section>
|
||||
@@ -32,37 +14,18 @@
|
||||
import { Container } from "@/models/Container";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { version } = config;
|
||||
|
||||
const containerStore = useContainerStore();
|
||||
const { containers, ready } = storeToRefs(containerStore) as unknown as {
|
||||
containers: Ref<Container[]>;
|
||||
ready: Ref<boolean>;
|
||||
};
|
||||
|
||||
const mostRecentContainers = $computed(() => [...containers.value].sort((a, b) => +b.created - +a.created));
|
||||
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 },
|
||||
);
|
||||
const runningContainers = computed(() => containers.value.filter((c) => c.state === "running"));
|
||||
|
||||
watchEffect(() => {
|
||||
if (ready.value) {
|
||||
setTitle(t("title.dashboard", { count: runningContainers.length }));
|
||||
setTitle(t("title.dashboard", { count: runningContainers.value.length }));
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label">
|
||||
<span class="label-text">Password</span>
|
||||
<span class="label-text">{{ $t("label.password") }}</span>
|
||||
</label>
|
||||
<input
|
||||
class="input input-bordered"
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { type Settings } from "@/stores/settings";
|
||||
import { Host } from "@/stores/hosts";
|
||||
|
||||
const text = document.querySelector("script#config__json")?.textContent || "{}";
|
||||
|
||||
type HostWithoutAvailable = Omit<Host, "available">;
|
||||
|
||||
export interface Config {
|
||||
version: string;
|
||||
base: string;
|
||||
maxLogs: number;
|
||||
hostname: string;
|
||||
hosts: { name: string; id: string }[];
|
||||
hosts: HostWithoutAvailable[];
|
||||
authProvider: "simple" | "none" | "forward-proxy";
|
||||
enableActions: boolean;
|
||||
user?: {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
type Host = {
|
||||
export type Host = {
|
||||
name: string;
|
||||
id: string;
|
||||
nCPU: number;
|
||||
memTotal: number;
|
||||
available: boolean;
|
||||
};
|
||||
const hosts = computed(() =>
|
||||
|
||||
@@ -9,10 +9,7 @@ test("has right title", async ({ page }) => {
|
||||
});
|
||||
|
||||
test("has dashboard text", async ({ page }) => {
|
||||
await expect(page.getByText("Total Containers")).toBeVisible();
|
||||
await expect(page.getByText("Total CPU Usage")).toBeVisible();
|
||||
await expect(page.getByText("Total Mem Usage")).toBeVisible();
|
||||
await expect(page.getByText("Dozzle Version")).toBeVisible();
|
||||
await expect(page.getByText("container name")).toBeVisible();
|
||||
});
|
||||
|
||||
test("click on settings button", async ({ page }) => {
|
||||
|
||||
@@ -9,14 +9,9 @@ toolbar:
|
||||
restart: Restart
|
||||
label:
|
||||
containers: Containers
|
||||
container: No containers | 1 container | {count} containers
|
||||
running-containers: Running 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
|
||||
password: Password
|
||||
username: Username
|
||||
@@ -26,6 +21,8 @@ label:
|
||||
avg-cpu: Avg. CPU (%)
|
||||
avg-mem: Avg. MEM (%)
|
||||
pinned: Pinned
|
||||
per-page: Rows per page
|
||||
|
||||
tooltip:
|
||||
search: Search containers (⌘ + k, ⌃k)
|
||||
pin-column: Pin as column
|
||||
|
||||
Reference in New Issue
Block a user