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
1
.gitignore
vendored
@@ -6,7 +6,6 @@ static
|
||||
dozzle
|
||||
coverage
|
||||
.pnpm-debug.log
|
||||
.vscode
|
||||
coverage.out
|
||||
.netlify
|
||||
/test-results/
|
||||
|
||||
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"i18n-ally.localesPaths": ["locales"],
|
||||
"i18n-ally.keystyle": "nested",
|
||||
"cSpell.words": ["healthcheck"],
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
3
assets/auto-imports.d.ts
vendored
@@ -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']>
|
||||
|
||||
3
assets/components.d.ts
vendored
@@ -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']
|
||||
|
||||
24
assets/components/BarChart.vue
Normal 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>
|
||||
@@ -44,7 +44,7 @@ const memoryData = computedWithControl(
|
||||
value: formatBytes(stat.snapshot.memoryUsage),
|
||||
}));
|
||||
return points;
|
||||
},
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
|
||||
11
assets/components/OrugaIcon.vue
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: "" });
|
||||
};
|
||||
@@ -1,123 +1,88 @@
|
||||
<template>
|
||||
<div>
|
||||
<section class="level section pb-0-is-mobile">
|
||||
<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="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 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 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 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>
|
||||
<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>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</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>
|
||||
</div>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 31 KiB |
|
Before Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 27 KiB |