mirror of
https://github.com/amir20/dozzle.git
synced 2026-06-23 04:10:12 +00:00
fix: stats charts stale for a few seconds after switching containers (#4738)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -249,7 +249,7 @@ The frontend uses file-based routing with these conventions:
|
||||
- All stat history tracked in `Container.statsHistory` (max 300 items via rolling window)
|
||||
- `chartData` is always a rolling window of max 300 items — array length stays constant
|
||||
- Uses `ref` (not `computed`) for `downsampledBars` to enable in-place mutation of the last bar, avoiding full re-renders
|
||||
- Component instance is reused when switching containers; parent must call exposed `recalculate()` to force refresh
|
||||
- Component instance is reused when switching containers; after init the chart only patches the last bar per tick, so on a wholesale `chartData` replacement (container switch) the parent must call the exposed `recalculate()`. `MultiContainerStat` holds refs to its `BarChart`s and calls it in the `containers` watch. (Note: `Container` carries Vue `ref`s, so VueTestUtils `setProps` cannot retrigger such a watch — tests must swap the container via a parent `ref` re-render.)
|
||||
|
||||
### Backend
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { nextTick } from "vue";
|
||||
import BarChart, { type BarDataPoint } from "./BarChart.vue";
|
||||
|
||||
// useElementSize relies on ResizeObserver which jsdom lacks, so the width stays
|
||||
// 0 and the chart never renders. Mock it with a controllable width ref that we
|
||||
// flip to a real value after mount to mimic the ResizeObserver firing.
|
||||
const holder = vi.hoisted(() => ({ width: null as ReturnType<typeof import("vue").ref<number>> | null }));
|
||||
vi.mock("@vueuse/core", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@vueuse/core")>();
|
||||
const { ref: vueRef } = await import("vue");
|
||||
holder.width = vueRef(0);
|
||||
return { ...actual, useElementSize: () => ({ width: holder.width, height: vueRef(0) }) };
|
||||
});
|
||||
|
||||
function ramp(start = 0, n = 300): BarDataPoint[] {
|
||||
return Array.from({ length: n }, (_, i) => ({ percent: start + i, value: start + i }));
|
||||
}
|
||||
|
||||
function constant(percent: number, n = 300): BarDataPoint[] {
|
||||
return Array.from({ length: n }, () => ({ percent, value: percent }));
|
||||
}
|
||||
|
||||
function heightOf(wrapper: ReturnType<typeof mount>, index: number): number {
|
||||
const style = wrapper.findAll(".bar")[index]?.attributes("style") ?? "";
|
||||
const match = style.match(/--height:\s*([\d.]+)%/);
|
||||
return match ? parseFloat(match[1]) : 0;
|
||||
}
|
||||
|
||||
async function mountAndRender(chartData: BarDataPoint[]) {
|
||||
// Mount with an unmeasured element, then simulate ResizeObserver reporting a
|
||||
// real width -> triggers the initial recalculate, like the live component.
|
||||
holder.width!.value = 0;
|
||||
const wrapper = mount(BarChart, { props: { chartData } });
|
||||
await nextTick();
|
||||
holder.width!.value = 300;
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
describe("<BarChart />", () => {
|
||||
test("exposed recalculate() rebuilds all bars after a wholesale data swap", async () => {
|
||||
// First container: a ramp where the oldest bars are near zero.
|
||||
const wrapper = await mountAndRender(ramp());
|
||||
expect(heightOf(wrapper, 0)).toBeLessThan(20); // oldest ramp bar is tiny
|
||||
|
||||
// A stat tick arrives: the rolling window shifts by one, marking the chart
|
||||
// initialized so further changes only patch the last bar.
|
||||
await wrapper.setProps({ chartData: ramp(1) });
|
||||
await nextTick();
|
||||
|
||||
// Switch containers: the whole series is replaced with a flat high value.
|
||||
// The chart caches bars and only patches the last one, so without help the
|
||||
// older bars stay stale.
|
||||
await wrapper.setProps({ chartData: constant(1000) });
|
||||
await nextTick();
|
||||
expect(heightOf(wrapper, 0)).toBeLessThan(20); // still stale
|
||||
|
||||
// The parent owns container switches and calls recalculate() to refresh.
|
||||
(wrapper.vm as unknown as { recalculate: () => void }).recalculate();
|
||||
await nextTick();
|
||||
expect(heightOf(wrapper, 0)).toBeGreaterThan(50); // flat series -> uniform height
|
||||
});
|
||||
|
||||
test("renders downsampled bars once width is known", async () => {
|
||||
const wrapper = await mountAndRender(constant(1000));
|
||||
expect(wrapper.findAll(".bar").length).toBeGreaterThan(0);
|
||||
expect(heightOf(wrapper, 0)).toBeGreaterThan(50);
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,9 @@ watch([availableBars, bucketSize], () => {
|
||||
changeCounter.value = 0;
|
||||
});
|
||||
|
||||
// On data changes, only update the last bar unless a new bucket boundary is crossed
|
||||
// On data changes, only update the last bar unless a new bucket boundary is crossed.
|
||||
// A wholesale replacement of the series (e.g. switching containers) is not detected
|
||||
// here; the parent owns that and must call the exposed recalculate() on switch.
|
||||
const changeCounter = ref(0);
|
||||
let initialized = false;
|
||||
watch(
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { flushPromises, mount } from "@vue/test-utils";
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import { createI18n } from "vue-i18n";
|
||||
import { defineComponent, h, nextTick, ref } from "vue";
|
||||
import { Container, Stat } from "@/models/Container";
|
||||
import MultiContainerStat from "./MultiContainerStat.vue";
|
||||
|
||||
vi.mock("@/stores/config", () => ({
|
||||
__esModule: true,
|
||||
default: { hosts: [], base: "" },
|
||||
withBase: (path: string) => path,
|
||||
}));
|
||||
|
||||
// Capture recalculate() across both chart instances.
|
||||
const recalculate = vi.fn();
|
||||
const BarChartStub = defineComponent({
|
||||
name: "BarChart",
|
||||
props: ["chartData", "barClass"],
|
||||
setup(_, { expose }) {
|
||||
expose({ recalculate });
|
||||
return () => h("div", { class: "bar-chart-stub" });
|
||||
},
|
||||
});
|
||||
|
||||
const i18n = createI18n({ legacy: false, locale: "en", missingWarn: false, fallbackWarn: false, messages: { en: {} } });
|
||||
|
||||
function stat(cpu: number): Stat {
|
||||
return {
|
||||
cpu,
|
||||
memory: cpu,
|
||||
memoryUsage: cpu,
|
||||
networkRxTotal: 0,
|
||||
networkTxTotal: 0,
|
||||
diskReadTotal: 0,
|
||||
diskWriteTotal: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function makeContainer(id: string, cpu: number): Container {
|
||||
const stats = Array.from({ length: 10 }, () => stat(cpu));
|
||||
const now = new Date();
|
||||
return new Container(id, now, now, now, "img", id, "cmd", "host1", {}, "running", 0, 0, stats);
|
||||
}
|
||||
|
||||
describe("<MultiContainerStat />", () => {
|
||||
test("recalculates the charts when the container changes", async () => {
|
||||
// Mirror production: a parent holding a ref re-renders with a fresh
|
||||
// [container] array on switch. (VueTestUtils setProps cannot trigger this
|
||||
// because Container instances carry refs, which defeats prop change
|
||||
// detection on a direct prop assignment.)
|
||||
const current = ref<Container>(makeContainer("a", 10));
|
||||
const Parent = defineComponent({
|
||||
setup: () => () => h(MultiContainerStat as any, { containers: [current.value] }),
|
||||
});
|
||||
mount(Parent, {
|
||||
global: { plugins: [i18n], stubs: { BarChart: BarChartStub, IOCard: true } },
|
||||
});
|
||||
await flushPromises();
|
||||
recalculate.mockClear();
|
||||
|
||||
// Switching containers replaces the whole stats series; the parent must
|
||||
// force the cached charts to fully recompute.
|
||||
current.value = makeContainer("b", 90);
|
||||
await nextTick();
|
||||
await flushPromises();
|
||||
|
||||
expect(recalculate).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,13 @@
|
||||
</span>
|
||||
</template>
|
||||
<template #chart="{ onHoverValue }">
|
||||
<Sparkline :data="cpuData" bar-class="bg-primary" class="max-md:hidden" @hover-value="onHoverValue" />
|
||||
<BarChart
|
||||
ref="cpuChart"
|
||||
:chart-data="cpuData"
|
||||
bar-class="bg-primary opacity-80 hover:opacity-100"
|
||||
class="h-5 w-full max-md:hidden"
|
||||
@hover-value="onHoverValue"
|
||||
/>
|
||||
</template>
|
||||
</StatCard>
|
||||
|
||||
@@ -43,7 +49,13 @@
|
||||
</span>
|
||||
</template>
|
||||
<template #chart="{ onHoverValue }">
|
||||
<Sparkline :data="memoryData" bar-class="bg-secondary" class="max-md:hidden" @hover-value="onHoverValue" />
|
||||
<BarChart
|
||||
ref="memoryChart"
|
||||
:chart-data="memoryData"
|
||||
bar-class="bg-secondary opacity-80 hover:opacity-100"
|
||||
class="h-5 w-full max-md:hidden"
|
||||
@hover-value="onHoverValue"
|
||||
/>
|
||||
</template>
|
||||
</StatCard>
|
||||
</div>
|
||||
@@ -53,7 +65,7 @@
|
||||
import { Container, Stat, emptyStat } from "@/models/Container";
|
||||
import StatCard from "@/components/LogViewer/StatCard.vue";
|
||||
import IOCard from "@/components/LogViewer/IOCard.vue";
|
||||
import Sparkline from "@/components/LogViewer/Sparkline.vue";
|
||||
import BarChart from "@/components/BarChart.vue";
|
||||
// @ts-ignore
|
||||
import PhCpu from "~icons/ph/cpu";
|
||||
// @ts-ignore
|
||||
@@ -68,6 +80,8 @@ const { t } = useI18n();
|
||||
const totalStat = ref<Stat>(emptyStat());
|
||||
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
|
||||
const { hosts } = useHosts();
|
||||
const cpuChart = useTemplateRef("cpuChart");
|
||||
const memoryChart = useTemplateRef("memoryChart");
|
||||
const networkRate = ref({ rx: 0, tx: 0 });
|
||||
const diskRate = ref({ read: 0, write: 0 });
|
||||
|
||||
@@ -106,6 +120,12 @@ watch(
|
||||
}
|
||||
totalStat.value = initial[0];
|
||||
reset({ initial: initial.reverse() });
|
||||
// Charts cache their downsampled bars and only patch the last bar per tick;
|
||||
// a container switch replaces the whole series, so force a full recalculate.
|
||||
nextTick(() => {
|
||||
cpuChart.value?.recalculate();
|
||||
memoryChart.value?.recalculate();
|
||||
});
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<template>
|
||||
<div class="block h-5 w-full">
|
||||
<BarChart
|
||||
:chart-data="data"
|
||||
:bar-class="`${barClass} opacity-80 hover:opacity-100`"
|
||||
class="h-5 w-full"
|
||||
@hover-value="(value: number) => emit('hoverValue', value)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import BarChart, { type BarDataPoint } from "@/components/BarChart.vue";
|
||||
|
||||
defineProps<{
|
||||
data: BarDataPoint[];
|
||||
barClass?: string;
|
||||
}>();
|
||||
const emit = defineEmits<{ hoverValue: [value: number] }>();
|
||||
</script>
|
||||
Reference in New Issue
Block a user