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']
|
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']
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
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>
|
<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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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?: {
|
||||||
|
|||||||
@@ -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(() =>
|
||||||
|
|||||||
@@ -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 }) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user