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

Add trending CPU and Memory usage (#1896)

* Refactors to stat history

* Uses markRaw

* Cleans up proxies

* Removes id from snapshots

* Adds d3

* Adds more d3 modules

* Fixes package

* Cleans up packages

* Updates modules

* Adds initital d3 chart

* Cleans up svg

* Fixes @types/d3-array

* Adds memory

* Moves charts around
This commit is contained in:
Amir Raminfar
2022-10-12 14:21:41 -07:00
committed by GitHub
parent 4f84beb835
commit afd37d3455
17 changed files with 550 additions and 145 deletions

View File

@@ -8,11 +8,11 @@ WORKDIR /build
# Install dependencies from lock file
COPY pnpm-lock.yaml ./
RUN pnpm fetch --prod
RUN pnpm fetch
# Copy package.json and install dependencies
COPY package.json ./
RUN pnpm install -r --offline --prod --ignore-scripts
RUN pnpm install -r --offline --ignore-scripts
# Copy assets and translations to build
COPY .* vite.config.ts index.html ./

View File

@@ -44,6 +44,7 @@ declare module '@vue/runtime-core' {
SideMenu: typeof import('./components/SideMenu.vue')['default']
SimpleLogItem: typeof import('./components/LogViewer/SimpleLogItem.vue')['default']
SkippedEntriesLogItem: typeof import('./components/LogViewer/SkippedEntriesLogItem.vue')['default']
StatSparkline: typeof import('./components/LogViewer/StatSparkline.vue')['default']
ZigZag: typeof import('./components/LogViewer/ZigZag.vue')['default']
}
}

View File

@@ -36,7 +36,7 @@
</template>
<script lang="ts" setup>
import { type Container } from "@/types/Container";
import { Container } from "@/models/Container";
import { useFuse } from "@vueuse/integrations/useFuse";
const { maxResults: resultLimit = 20 } = defineProps<{

View File

@@ -1,16 +1,23 @@
<template>
<div class="is-size-7 is-uppercase columns is-marginless is-mobile" v-if="container.stat">
<div class="is-size-7 is-uppercase columns is-marginless is-mobile is-vcentered" v-if="container.stat">
<div class="column is-narrow has-text-weight-bold">
{{ container.state }}
</div>
<div class="column is-narrow" v-if="container.stat.memoryUsage !== null">
<div class="column is-narrow has-text-centered">
<div>
<stat-sparkline :data="memoryData"></stat-sparkline>
</div>
<span class="has-text-weight-light has-spacer">mem</span>
<span class="has-text-weight-bold">
{{ formatBytes(container.stat.memoryUsage) }}
</span>
</div>
<div class="column is-narrow"></div>
<div class="column is-narrow" v-if="container.stat.cpu !== null">
<div class="column is-narrow has-text-centered">
<div>
<stat-sparkline :data="cpuData"></stat-sparkline>
</div>
<span class="has-text-weight-light has-spacer">load</span>
<span class="has-text-weight-bold"> {{ container.stat.cpu }}% </span>
</div>
@@ -18,10 +25,26 @@
</template>
<script lang="ts" setup>
import { type Container } from "@/types/Container";
import { Container } from "@/models/Container";
import { type ComputedRef } from "vue";
const container = inject("container") as ComputedRef<Container>;
const cpuData = computedWithControl(
() => container.value.getLastStat(),
() => {
const history = container.value.getStatHistory();
return history.map((stat, i) => ({ x: history.length - i, y: stat.snapshot.cpu }));
}
);
const memoryData = computedWithControl(
() => container.value.getLastStat(),
() => {
const history = container.value.getStatHistory();
return history.map((stat, i) => ({ x: history.length - i, y: stat.snapshot.memory }));
}
);
</script>
<style lang="scss" scoped>

View File

@@ -8,7 +8,7 @@
</template>
<script lang="ts" setup>
import { type Container } from "@/types/Container";
import { Container } from "@/models/Container";
import { type ComputedRef } from "vue";
const container = inject("container") as ComputedRef<Container>;

View File

@@ -42,7 +42,7 @@
<script lang="ts" setup>
import { type ComputedRef } from "vue";
import { type Container } from "@/types/Container";
import { Container } from "@/models/Container";
const { showSearch } = useSearchFilter();
const { base } = config;

View File

@@ -6,7 +6,7 @@
<container-title @close="$emit('close')" />
</div>
<div class="column is-narrow is-paddingless">
<container-stat v-if="container.stat" />
<container-stat />
</div>
<div class="mr-2 column is-narrow is-paddingless">

View File

@@ -4,7 +4,7 @@
</template>
<script lang="ts" setup>
import { type Container } from "@/types/Container";
import { Container } from "@/models/Container";
import { type ComputedRef } from "vue";
const emit = defineEmits<{

View File

@@ -30,7 +30,7 @@
<script lang="ts" setup>
import { type ComputedRef, toRaw } from "vue";
import { useRouteHash } from "@vueuse/router";
import { type Container } from "@/types/Container";
import { Container } from "@/models/Container";
import { type JSONObject, LogEntry } from "@/models/LogEntry";
const props = defineProps<{

View File

@@ -0,0 +1,54 @@
<template>
<svg width="150" height="20"></svg>
</template>
<script lang="ts" setup>
import { select, type ValueFn } from "d3-selection";
import { extent } from "d3-array";
import { scaleLinear } from "d3-scale";
import { area, curveStep } from "d3-shape";
const d3 = { select, extent, scaleLinear, area, curveStep };
const root = useCurrentElement();
const { data } = defineProps<{ data: { x: number; y: number }[] }>();
onMounted(() => {
const svg = d3.select(root.value);
const width = +svg.attr("width");
const height = +svg.attr("height");
const margin = { top: 0, right: 0, bottom: 0, left: 0 };
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
const x = d3.scaleLinear().range([0, innerWidth]);
const y = d3.scaleLinear().range([innerHeight, 0]);
const g = svg.append("g").attr("transform", `translate(${margin.left}, ${margin.top})`);
const path = g.append("path").attr("class", "area");
const area = d3
.area()
.curve(d3.curveStep)
.x((d: any) => x(d.x))
.y0(y(0))
.y1((d: any) => y(d.y)) as ValueFn<SVGGElement, any, string>;
watchEffect(() => {
x.domain(d3.extent(data, (d) => d.x) as [number, number]);
y.domain(d3.extent(data, (d) => d.y) as [number, number]);
path.datum(data).attr("d", area);
});
});
</script>
<style scoped>
:deep(.area) {
fill: var(--primary-color);
stroke: var(--primary-color);
stroke-width: 1;
}
</style>

View File

@@ -8,7 +8,7 @@ import {
DockerEventLogEntry,
SkippedLogsEntry,
} from "@/models/LogEntry";
import { type Container } from "@/types/Container";
import { Container } from "@/models/Container";
function parseMessage(data: string): LogEntry<string | JSONObject> {
const e = JSON.parse(data) as LogEvent;

View File

@@ -0,0 +1,31 @@
import type { ContainerStat, ContainerState } from "@/types/Container";
import type { UseThrottledRefHistoryReturn } from "@vueuse/core";
import { Ref } from "vue";
type Stat = Omit<ContainerStat, "id">;
export class Container {
public stat: Ref<Stat>;
private readonly throttledStatHistory: UseThrottledRefHistoryReturn<Stat, Stat>;
constructor(
public readonly id: string,
public readonly created: number,
public readonly image: string,
public readonly name: string,
public readonly command: string,
public status: string,
public state: ContainerState
) {
this.stat = ref({ cpu: 0, memory: 0, memoryUsage: 0 });
this.throttledStatHistory = useThrottledRefHistory(this.stat, { capacity: 300, deep: true, throttle: 1000 });
}
public getStatHistory() {
return unref(this.throttledStatHistory.history);
}
public getLastStat() {
return unref(this.throttledStatHistory.last);
}
}

View File

@@ -1,14 +1,11 @@
import { acceptHMRUpdate, defineStore } from "pinia";
import { ref, Ref, computed } from "vue";
import { showAllContainers } from "@/composables/settings";
import config from "@/stores/config";
import type { Container, ContainerStat } from "@/types/Container";
import { watchOnce } from "@vueuse/core";
import { Ref, UnwrapNestedRefs } from "vue";
import type { ContainerJson, ContainerStat } from "@/types/Container";
import { Container } from "@/models/Container";
export const useContainerStore = defineStore("container", () => {
const containers = ref<Container[]>([]);
const activeContainerIds = ref<string[]>([]);
const containers: Ref<Container[]> = ref([]);
const activeContainerIds: Ref<string[]> = ref([]);
const allContainersById = computed(() =>
containers.value.reduce((acc, container) => {
@@ -25,33 +22,36 @@ export const useContainerStore = defineStore("container", () => {
const activeContainers = computed(() => activeContainerIds.value.map((id) => allContainersById.value[id]));
const es = new EventSource(`${config.base}/api/events/stream`);
es.addEventListener(
"containers-changed",
(e: Event) => (containers.value = JSON.parse((e as MessageEvent).data)),
false
es.addEventListener("containers-changed", (e: Event) =>
setContainers(JSON.parse((e as MessageEvent).data) as ContainerJson[])
);
es.addEventListener(
"container-stat",
(e) => {
const stat = JSON.parse((e as MessageEvent).data) as ContainerStat;
const container = allContainersById.value[stat.id];
if (container) {
container.stat = stat;
es.addEventListener("container-stat", (e) => {
const stat = JSON.parse((e as MessageEvent).data) as ContainerStat;
const container = allContainersById.value[stat.id] as unknown as UnwrapNestedRefs<Container>;
if (container) {
const { id, ...rest } = stat;
container.stat = rest;
}
});
es.addEventListener("container-die", (e) => {
const event = JSON.parse((e as MessageEvent).data) as { actorId: string };
const container = allContainersById.value[event.actorId];
if (container) {
container.state = "dead";
}
});
const setContainers = (newContainers: ContainerJson[]) => {
containers.value = newContainers.map((c) => {
const existing = allContainersById.value[c.id];
if (existing) {
existing.status = c.status;
existing.state = c.state;
return existing;
}
},
false
);
es.addEventListener(
"container-die",
(e) => {
const event = JSON.parse((e as MessageEvent).data) as { actorId: string };
const container = allContainersById.value[event.actorId];
if (container) {
container.state = "dead";
}
},
false
);
return new Container(c.id, c.created, c.image, c.name, c.command, c.status, c.state);
});
};
const currentContainer = (id: Ref<string>) => computed(() => allContainersById.value[id.value]);
const appendActiveContainer = ({ id }: Container) => activeContainerIds.value.push(id);

View File

@@ -1,17 +1,18 @@
export interface Container {
readonly id: string;
readonly created: number;
readonly image: string;
readonly name: string;
readonly status: string;
readonly command: string;
state: "created" | "running" | "exited" | "dead" | "paused" | "restarting";
stat?: ContainerStat;
}
export interface ContainerStat {
readonly id: string;
readonly cpu: number;
readonly memory: number;
readonly memoryUsage: number;
}
export type ContainerJson = {
readonly id: string;
readonly created: number;
readonly image: string;
readonly name: string;
readonly command: string;
readonly status: string;
readonly state: ContainerState;
};
export type ContainerState = "created" | "running" | "exited" | "dead" | "paused" | "restarting";

View File

@@ -1,4 +1,4 @@
import { Container } from "@/types/Container";
import { Container } from "@/models/Container";
import { useStorage } from "@vueuse/core";
import { computed, ComputedRef } from "vue";

View File

@@ -27,39 +27,43 @@
"@iconify-json/mdi": "^1.1.33",
"@iconify-json/mdi-light": "^1.1.2",
"@iconify-json/octicon": "^1.1.20",
"@intlify/vite-plugin-vue-i18n": "^6.0.3",
"@oruga-ui/oruga-next": "^0.5.6",
"@oruga-ui/theme-bulma": "^0.2.7",
"@vitejs/plugin-vue": "3.1.2",
"@vue/compiler-sfc": "^3.2.40",
"@vueuse/core": "^9.3.0",
"@vueuse/integrations": "^9.3.0",
"@vueuse/router": "^9.3.0",
"ansi-to-html": "^0.7.2",
"bulma": "^0.9.4",
"d3-array": "^3.2.0",
"d3-ease": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-selection": "^3.0.0",
"d3-shape": "^3.1.0",
"d3-transition": "^3.0.1",
"date-fns": "^2.29.3",
"fuse.js": "^6.6.2",
"lodash.debounce": "^4.0.8",
"pinia": "^2.0.23",
"sass": "^1.55.0",
"semver": "^7.3.8",
"splitpanes": "^3.1.1",
"typescript": "^4.8.4",
"unplugin-auto-import": "^0.11.2",
"unplugin-icons": "^0.14.11",
"unplugin-vue-components": "^0.22.8",
"vite": "3.1.7",
"vite-plugin-pages": "^0.26.0",
"vite-plugin-vue-layouts": "^0.7.0",
"vue": "^3.2.40",
"vue-i18n": "^9.2.2",
"vue-router": "^4.1.5"
},
"devDependencies": {
"@intlify/vite-plugin-vue-i18n": "^6.0.3",
"@pinia/testing": "^0.0.14",
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-scale": "^4.0.2",
"@types/d3-selection": "^3.0.3",
"@types/d3-shape": "^3.1.0",
"@types/d3-transition": "^3.0.2",
"@types/lodash.debounce": "^4.0.7",
"@types/node": "^18.8.4",
"@types/semver": "^7.3.12",
"@vitejs/plugin-vue": "3.1.2",
"@vue/compiler-sfc": "^3.2.40",
"@vue/test-utils": "^2.1.0",
"c8": "^7.12.0",
"eventsourcemock": "^2.0.0",
@@ -70,7 +74,15 @@
"npm-run-all": "^4.1.5",
"prettier": "^2.7.1",
"release-it": "^15.5.0",
"sass": "^1.55.0",
"ts-node": "^10.9.1",
"typescript": "^4.8.4",
"unplugin-auto-import": "^0.11.2",
"unplugin-icons": "^0.14.11",
"unplugin-vue-components": "^0.22.8",
"vite": "3.1.7",
"vite-plugin-pages": "^0.26.0",
"vite-plugin-vue-layouts": "^0.7.0",
"vitest": "^0.24.1",
"vue-tsc": "^1.0.3"
},

435
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff