diff --git a/CLAUDE.md b/CLAUDE.md index 787540c0..b0434529 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/assets/components/BarChart.spec.ts b/assets/components/BarChart.spec.ts new file mode 100644 index 00000000..b6dde5b8 --- /dev/null +++ b/assets/components/BarChart.spec.ts @@ -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> | null })); +vi.mock("@vueuse/core", async (importOriginal) => { + const actual = await importOriginal(); + 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, 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("", () => { + 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); + }); +}); diff --git a/assets/components/BarChart.vue b/assets/components/BarChart.vue index ee4b8132..414030c5 100644 --- a/assets/components/BarChart.vue +++ b/assets/components/BarChart.vue @@ -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( diff --git a/assets/components/LogViewer/MultiContainerStat.spec.ts b/assets/components/LogViewer/MultiContainerStat.spec.ts new file mode 100644 index 00000000..c4ce8f41 --- /dev/null +++ b/assets/components/LogViewer/MultiContainerStat.spec.ts @@ -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("", () => { + 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(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(); + }); +}); diff --git a/assets/components/LogViewer/MultiContainerStat.vue b/assets/components/LogViewer/MultiContainerStat.vue index 0a4796f2..7476652c 100644 --- a/assets/components/LogViewer/MultiContainerStat.vue +++ b/assets/components/LogViewer/MultiContainerStat.vue @@ -20,7 +20,13 @@ @@ -43,7 +49,13 @@ @@ -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(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 }, ); diff --git a/assets/components/LogViewer/Sparkline.vue b/assets/components/LogViewer/Sparkline.vue deleted file mode 100644 index 5eb3e4eb..00000000 --- a/assets/components/LogViewer/Sparkline.vue +++ /dev/null @@ -1,20 +0,0 @@ - - -