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:
@@ -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 ./
|
||||
|
||||
1
assets/components.d.ts
vendored
1
assets/components.d.ts
vendored
@@ -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']
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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<{
|
||||
|
||||
@@ -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<{
|
||||
|
||||
54
assets/components/LogViewer/StatSparkline.vue
Normal file
54
assets/components/LogViewer/StatSparkline.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
31
assets/models/Container.ts
Normal file
31
assets/models/Container.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
23
assets/types/Container.d.ts
vendored
23
assets/types/Container.d.ts
vendored
@@ -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";
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Container } from "@/types/Container";
|
||||
import { Container } from "@/models/Container";
|
||||
import { useStorage } from "@vueuse/core";
|
||||
import { computed, ComputedRef } from "vue";
|
||||
|
||||
|
||||
34
package.json
34
package.json
@@ -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
435
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user