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

feat: adds exponential average and a new dashboard showing all containers (#2317)

* chore: updates modules

* feat: adds support for exponential moving average

* feat: add expoentital moving avg

* adds index page

* cleans up table

* fixes typecheck

* adds bar chart

* updates dashboard to orgua table

* wip

* cleans up ui

* remove screenshot tests for playwright

* adds more tests

* fixes icon

* fixes default sort

* removes unused var

* adds vscode settings
This commit is contained in:
Amir Raminfar
2023-07-24 15:06:42 -07:00
committed by GitHub
parent 18005fcd23
commit 1b056346c0
24 changed files with 170 additions and 148 deletions

1
.gitignore vendored
View File

@@ -6,7 +6,6 @@ static
dozzle
coverage
.pnpm-debug.log
.vscode
coverage.out
.netlify
/test-results/

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"i18n-ally.localesPaths": ["locales"],
"i18n-ally.keystyle": "nested",
"cSpell.words": ["healthcheck"],
"editor.formatOnSave": true
}

View File

@@ -204,6 +204,7 @@ declare global {
const useEventBus: typeof import('@vueuse/core')['useEventBus']
const useEventListener: typeof import('@vueuse/core')['useEventListener']
const useEventSource: typeof import('@vueuse/core')['useEventSource']
const useExponentialMovingAverage: typeof import('./utils/index')['useExponentialMovingAverage']
const useEyeDropper: typeof import('@vueuse/core')['useEyeDropper']
const useFavicon: typeof import('@vueuse/core')['useFavicon']
const useFetch: typeof import('@vueuse/core')['useFetch']
@@ -540,6 +541,7 @@ declare module 'vue' {
readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
readonly useExponentialMovingAverage: UnwrapRef<typeof import('./utils/index')['useExponentialMovingAverage']>
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>
@@ -870,6 +872,7 @@ declare module '@vue/runtime-core' {
readonly useEventBus: UnwrapRef<typeof import('@vueuse/core')['useEventBus']>
readonly useEventListener: UnwrapRef<typeof import('@vueuse/core')['useEventListener']>
readonly useEventSource: UnwrapRef<typeof import('@vueuse/core')['useEventSource']>
readonly useExponentialMovingAverage: UnwrapRef<typeof import('./utils/index')['useExponentialMovingAverage']>
readonly useEyeDropper: UnwrapRef<typeof import('@vueuse/core')['useEyeDropper']>
readonly useFavicon: UnwrapRef<typeof import('@vueuse/core')['useFavicon']>
readonly useFetch: UnwrapRef<typeof import('@vueuse/core')['useFetch']>

View File

@@ -7,6 +7,7 @@ export {}
declare module 'vue' {
export interface GlobalComponents {
BarChart: typeof import('./components/BarChart.vue')['default']
'Carbon:caretDown': typeof import('~icons/carbon/caret-down')['default']
'Carbon:circleSolid': typeof import('~icons/carbon/circle-solid')['default']
'Cil:checkCircle': typeof import('~icons/cil/check-circle')['default']
@@ -34,6 +35,7 @@ declare module 'vue' {
LogStd: typeof import('./components/LogViewer/LogStd.vue')['default']
LogViewer: typeof import('./components/LogViewer/LogViewer.vue')['default']
LogViewerWithSource: typeof import('./components/LogViewer/LogViewerWithSource.vue')['default']
'Mdi:arrowUp': typeof import('~icons/mdi/arrow-up')['default']
'Mdi:dotsVertical': typeof import('~icons/mdi/dots-vertical')['default']
'Mdi:lightChevronDoubleDown': typeof import('~icons/mdi-light/chevron-double-down')['default']
'Mdi:lightChevronLeft': typeof import('~icons/mdi-light/chevron-left')['default']
@@ -45,6 +47,7 @@ declare module 'vue' {
'Octicon:container24': typeof import('~icons/octicon/container24')['default']
'Octicon:download24': typeof import('~icons/octicon/download24')['default']
'Octicon:trash24': typeof import('~icons/octicon/trash24')['default']
OrugaIcon: typeof import('./components/OrugaIcon.vue')['default']
Popup: typeof import('./components/Popup.vue')['default']
RouterLink: typeof import('vue-router')['RouterLink']
RouterView: typeof import('vue-router')['RouterView']

View File

@@ -0,0 +1,24 @@
<template>
<div class="is-relative">
<div class="bar"></div>
<div class="is-overlay">
<slot></slot>
</div>
</div>
</template>
<script lang="ts" setup>
const { value } = defineProps<{ value: number }>();
const minValue = computed(() => Math.min(value, 1));
</script>
<style scoped>
.bar {
height: 100%;
background-color: var(--primary-color);
transform-origin: left;
transform: scaleX(v-bind(minValue));
transition: transform 0.2s ease-out;
}
</style>

View File

@@ -44,7 +44,7 @@ const memoryData = computedWithControl(
value: formatBytes(stat.snapshot.memoryUsage),
}));
return points;
},
}
);
</script>

View File

@@ -0,0 +1,11 @@
<template>
<div>
<mdi:arrow-up v-if="icon[1] == 'arrow-up'" />
</div>
</template>
<script lang="ts" setup>
const { icon } = defineProps<{ icon: string[] }>();
</script>
<style scoped></style>

View File

@@ -54,7 +54,7 @@
</ul>
</transition>
</div>
<ul class="menu-list is-hidden-mobile loading" v-else>
<ul class="menu-list is-hidden-mobile has-light-opacity" v-else>
<li v-for="index in 7" class="my-4"><o-skeleton animated size="large" :key="index"></o-skeleton></li>
</ul>
</template>
@@ -110,7 +110,7 @@ const activeContainersById = computed(() =>
opacity: 0.5;
}
.loading {
.has-light-opacity {
opacity: 0.5;
}

View File

@@ -17,6 +17,9 @@ const {
}>();
const text = ref<string>();
watch($$(date), updateFromNow, { immediate: true });
function updateFromNow() {
const fn = strict ? formatDistanceToNowStrict : formatDistanceToNow;
text.value = fn(date, {

View File

@@ -1,5 +1,6 @@
import type { ContainerHealth, ContainerStat, ContainerState } from "@/types/Container";
import type { UseThrottledRefHistoryReturn } from "@vueuse/core";
import { useExponentialMovingAverage } from "@/utils";
import { Ref } from "vue";
type Stat = Omit<ContainerStat, "id">;
@@ -11,6 +12,7 @@ export class Container {
private readonly throttledStatHistory: UseThrottledRefHistoryReturn<Stat, Stat>;
public readonly swarmId: string | null = null;
public readonly isSwarm: boolean = false;
public readonly movingAverageStat: Ref<Stat>;
constructor(
public readonly id: string,
@@ -25,6 +27,7 @@ export class Container {
) {
this.stat = ref({ cpu: 0, memory: 0, memoryUsage: 0 });
this.throttledStatHistory = useThrottledRefHistory(this.stat, { capacity: 300, deep: true, throttle: 1000 });
this.movingAverageStat = useExponentialMovingAverage(this.stat, 0.2);
const match = name.match(SWARM_ID_REGEX);
if (match) {

View File

@@ -1,7 +1,7 @@
import { type App } from "vue";
import { Autocomplete, Button, Dropdown, Switch, Skeleton, Field, Modal, Config } from "@oruga-ui/oruga-next";
import { Autocomplete, Button, Dropdown, Switch, Skeleton, Field, Table, Modal, Config } from "@oruga-ui/oruga-next";
import { bulmaConfig } from "@oruga-ui/theme-bulma";
import OrugaIcon from "@/components/OrugaIcon.vue";
export const install = (app: App) => {
app
.use(Autocomplete)
@@ -11,5 +11,7 @@ export const install = (app: App) => {
.use(Modal)
.use(Field)
.use(Skeleton)
.use(Config, bulmaConfig);
.use(Table)
.component("oruga-icon", OrugaIcon)
.use(Config, { ...bulmaConfig, iconComponent: "oruga-icon", iconPack: "" });
};

View File

@@ -1,123 +1,88 @@
<template>
<div>
<section class="level section pb-0-is-mobile">
<div class="section tile is-ancestor">
<div class="tile is-parent">
<div class="tile is-child box">
<div class="level-item has-text-centered">
<div>
<p class="title">{{ containers.length }}</p>
<p class="heading">{{ $t("label.total-containers") }}</p>
</div>
</div>
<div class="level-item has-text-centered">
<div>
<p class="title">{{ runningContainers.length }}</p>
<p class="heading">{{ $t("label.running") }}</p>
</div>
</div>
<div class="tile is-parent">
<div class="tile is-child box">
<div class="level-item has-text-centered" data-ci-skip>
<div>
<p class="title">{{ totalCpu }}%</p>
<p class="heading">{{ $t("label.total-cpu-usage") }}</p>
</div>
</div>
</div>
</div>
<div class="tile is-parent">
<div class="tile is-child box">
<div class="level-item has-text-centered" data-ci-skip>
<div>
<p class="title">{{ formatBytes(totalMem) }}</p>
<p class="heading">{{ $t("label.total-mem-usage") }}</p>
</div>
</div>
</div>
</div>
<div class="tile is-parent">
<div class="tile is-child box">
<div class="level-item has-text-centered">
<div>
<p class="title">{{ version }}</p>
<p class="heading">{{ $t("label.dozzle-version") }}</p>
</div>
</div>
</section>
<section class="columns is-centered section is-marginless pt-0-is-mobile">
<div class="column is-12-mobile is-6-tablet is-5-desktop is-4-fullhd">
<div class="panel">
<p class="panel-heading">{{ $t("label.containers") }}</p>
<div class="panel-block">
<p class="control has-icons-left">
<input
class="input"
type="text"
:placeholder="$t('placeholder.search-containers')"
v-model="query"
@keyup.esc="query = ''"
@keyup.enter="onEnter()"
/>
<span class="icon is-left">
<mdi:light-magnify />
</span>
</p>
</div>
<p class="panel-tabs" v-if="query === ''">
<a :class="{ 'is-active': sort === 'running' }" @click="sort = 'running'">{{ $t("label.running") }}</a>
<a :class="{ 'is-active': sort === 'all' }" @click="sort = 'all'">{{ $t("label.all") }}</a>
</p>
<router-link
:to="{ name: 'container-id', params: { id: item.id } }"
v-for="item in data.slice(0, 10)"
:key="item.id"
class="panel-block"
>
<span class="name">{{ item.name }}</span>
<div class="subtitle is-7 status">
<distance-time :date="item.created"></distance-time>
</div>
</div>
<section class="section table-container">
<div class="box" data-ci-skip>
<o-table :data="runningContainers" :defaultSort="['created', 'desc']">
<o-table-column #default="{ row: container }" label="Container Name" sortable field="name">
<router-link :to="{ name: 'container-id', params: { id: container.id } }" :title="container.name">
{{ container.name }}
</router-link>
</o-table-column>
<o-table-column #default="{ row: container }" label="State" sortable field="state">
{{ container.state }}
</o-table-column>
<o-table-column #default="{ row: container }" label="Running" sortable field="created">
<distance-time :date="container.created" strict :suffix="false"></distance-time>
</o-table-column>
<o-table-column #default="{ row: container }" label="Avg. CPU" sortable field="movingAverageStat.cpu">
<bar-chart :value="container.movingAverageStat.cpu / 100" class="bar-chart">
<div class="bar-text">
{{ (container.movingAverageStat.cpu / 100).toLocaleString(undefined, { style: "percent" }) }}
</div>
</bar-chart>
</o-table-column>
<o-table-column #default="{ row: container }" label="Avg. Memory" sortable field="movingAverageStat.memory">
<bar-chart :value="container.movingAverageStat.memory / 100" class="bar-chart">
<div class="bar-text">
{{ formatBytes(container.movingAverageStat.memoryUsage) }}
</div>
</bar-chart>
</o-table-column>
</o-table>
</div>
</section>
</div>
</template>
<script lang="ts" setup>
import { useFuse } from "@vueuse/integrations/useFuse";
const { version } = config;
const containerStore = useContainerStore();
const { containers } = storeToRefs(containerStore);
const router = useRouter();
const sort = $ref("running");
const query = ref("");
const mostRecentContainers = $computed(() => [...containers.value].sort((a, b) => +b.created - +a.created));
const runningContainers = $computed(() => mostRecentContainers.filter((c) => c.state === "running"));
const list = computed(() => {
return containers.value.map(({ id, created, name, state }) => {
return {
id,
created,
name,
state,
};
});
});
const { results } = useFuse(query, list, {
fuseOptions: { keys: ["name"] },
matchAllWhenSearchEmpty: false,
});
const data = computed(() => {
if (results.value.length) {
return results.value.map(({ item }) => item);
}
switch (sort) {
case "all":
return mostRecentContainers;
case "running":
return runningContainers;
default:
throw `Invalid sort order: ${sort}`;
}
});
let totalCpu = $ref(0);
useIntervalFn(
() => {
@@ -135,13 +100,6 @@ useIntervalFn(
1000,
{ immediate: true },
);
function onEnter() {
if (data.value.length > 0) {
const item = data.value[0];
router.push({ name: "container-id", params: { id: item.id } });
}
}
</script>
<style lang="scss" scoped>
.panel {
@@ -181,4 +139,17 @@ function onEnter() {
.icon {
padding: 10px 3px;
}
.bar-chart {
height: 1.5em;
.bar-text {
font-size: 0.9em;
padding: 0 0.5em;
}
}
:deep(tr td) {
padding-top: 1em;
padding-bottom: 1em;
}
</style>

View File

@@ -28,14 +28,8 @@ $link-active: $grey-dark;
$dark-toolbar-color: rgba($black-bis, 0.7);
$light-toolbar-color: rgba($grey-darker, 0.7);
@import "bulma/bulma.sass";
@import "@oruga-ui/theme-bulma/dist/scss/components/utils/all.scss";
@import "@oruga-ui/theme-bulma/dist/scss/components/autocomplete.scss";
@import "@oruga-ui/theme-bulma/dist/scss/components/button.scss";
@import "@oruga-ui/theme-bulma/dist/scss/components/modal.scss";
@import "@oruga-ui/theme-bulma/dist/scss/components/switch.scss";
@import "@oruga-ui/theme-bulma/dist/scss/components/dropdown.scss";
@import "@oruga-ui/theme-bulma/dist/scss/components/skeleton.scss";
@import "bulma/bulma";
@import "@oruga-ui/theme-bulma/dist/scss/bulma";
@import "splitpanes/dist/splitpanes.css";
@mixin dark {
@@ -218,7 +212,9 @@ html.has-custom-scrollbars {
}
.has-boxshadow {
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
box-shadow:
0 1px 3px 0 rgb(0 0 0 / 0.1),
0 1px 2px -1px rgb(0 0 0 / 0.1);
}
mark {

View File

@@ -37,3 +37,17 @@ export function stripVersion(label: string) {
const [name, _] = label.split(":");
return name;
}
export function useExponentialMovingAverage<T extends Record<string, number>>(source: Ref<T>, alpha: number = 0.2) {
const ema = ref<T>(source.value) as Ref<T>;
watch(source, (value) => {
const newValue = {} as Record<string, number>;
for (const key in value) {
newValue[key] = alpha * value[key] + (1 - alpha) * ema.value[key];
}
ema.value = newValue as T;
});
return ema;
}

View File

@@ -8,6 +8,13 @@ test("has right title", async ({ page }) => {
await expect(page).toHaveTitle(/.* - Dozzle/);
});
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();
});
test("click on settings button", async ({ page }) => {
await page.getByRole("link", { name: "Settings" }).click();
await expect(page.getByRole("heading", { name: "About" })).toBeVisible();

View File

@@ -1,20 +0,0 @@
import { test, expect } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("http://dozzle:8080/");
});
test.describe("default", () => {
test("homepage", async ({ page }) => {
await page.addStyleTag({ content: `[data-ci-skip] { visibility: hidden; }` });
await expect(page).toHaveScreenshot({});
});
});
test.describe("dark", () => {
test.use({ colorScheme: "dark" });
test("homepage", async ({ page }) => {
await page.addStyleTag({ content: `[data-ci-skip] { visibility: hidden; }` });
await expect(page).toHaveScreenshot({});
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB