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:
Amir Raminfar
2026-05-24 09:59:27 -07:00
committed by GitHub
parent 1db40bc4cd
commit ab6c93a679
6 changed files with 174 additions and 25 deletions
+1 -1
View File
@@ -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
+75
View File
@@ -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);
});
});
+3 -1
View File
@@ -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 },
); );
-20
View File
@@ -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>