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)
|
- 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
|
- `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
|
- 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
|
### 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;
|
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);
|
const changeCounter = ref(0);
|
||||||
let initialized = false;
|
let initialized = false;
|
||||||
watch(
|
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>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template #chart="{ onHoverValue }">
|
<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>
|
</template>
|
||||||
</StatCard>
|
</StatCard>
|
||||||
|
|
||||||
@@ -43,7 +49,13 @@
|
|||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<template #chart="{ onHoverValue }">
|
<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>
|
</template>
|
||||||
</StatCard>
|
</StatCard>
|
||||||
</div>
|
</div>
|
||||||
@@ -53,7 +65,7 @@
|
|||||||
import { Container, Stat, emptyStat } from "@/models/Container";
|
import { Container, Stat, emptyStat } from "@/models/Container";
|
||||||
import StatCard from "@/components/LogViewer/StatCard.vue";
|
import StatCard from "@/components/LogViewer/StatCard.vue";
|
||||||
import IOCard from "@/components/LogViewer/IOCard.vue";
|
import IOCard from "@/components/LogViewer/IOCard.vue";
|
||||||
import Sparkline from "@/components/LogViewer/Sparkline.vue";
|
import BarChart from "@/components/BarChart.vue";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
import PhCpu from "~icons/ph/cpu";
|
import PhCpu from "~icons/ph/cpu";
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@@ -68,6 +80,8 @@ const { t } = useI18n();
|
|||||||
const totalStat = ref<Stat>(emptyStat());
|
const totalStat = ref<Stat>(emptyStat());
|
||||||
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
|
const { history, reset } = useSimpleRefHistory(totalStat, { capacity: 300 });
|
||||||
const { hosts } = useHosts();
|
const { hosts } = useHosts();
|
||||||
|
const cpuChart = useTemplateRef("cpuChart");
|
||||||
|
const memoryChart = useTemplateRef("memoryChart");
|
||||||
const networkRate = ref({ rx: 0, tx: 0 });
|
const networkRate = ref({ rx: 0, tx: 0 });
|
||||||
const diskRate = ref({ read: 0, write: 0 });
|
const diskRate = ref({ read: 0, write: 0 });
|
||||||
|
|
||||||
@@ -106,6 +120,12 @@ watch(
|
|||||||
}
|
}
|
||||||
totalStat.value = initial[0];
|
totalStat.value = initial[0];
|
||||||
reset({ initial: initial.reverse() });
|
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 },
|
{ 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