From 3f6a7e18ba4ed2f01acd657e986b74201c5fd146 Mon Sep 17 00:00:00 2001 From: Ashish Kolhe Date: Mon, 2 Nov 2026 14:14:01 +0530 Subject: [PATCH] fix(autoobserve): age Last checked while the coverage page stays open The live coverage source reads the shared ticking clock, rounded to the minute, so Last checked and the pass age advance without new data. --- .../autoobserve/live/liveAdapter.ts | 7 +- .../autoobserve/live/liveModel.spec.ts | 81 +++++++++++++++++++ .../components/autoobserve/live/liveModel.ts | 12 ++- 3 files changed, 96 insertions(+), 4 deletions(-) create mode 100644 web/src/components/autoobserve/live/liveModel.spec.ts diff --git a/web/src/components/autoobserve/live/liveAdapter.ts b/web/src/components/autoobserve/live/liveAdapter.ts index 4fb4a95100..652b7f4623 100644 --- a/web/src/components/autoobserve/live/liveAdapter.ts +++ b/web/src/components/autoobserve/live/liveAdapter.ts @@ -299,8 +299,8 @@ const uniqueNames = (rows: AutoObserveCoverageRow[]) => { }); }; -const minutesSince = (passAt: number | null) => - passAt ? Math.max(0, Math.floor((Date.now() - passAt / 1000) / 60_000)) : null; +const minutesSince = (passAt: number | null, now: number) => + passAt ? Math.max(0, Math.floor((now - passAt / 1000) / 60_000)) : null; const toRow = ( t: TranslateFn, @@ -365,12 +365,13 @@ export const toCoverageSource = ( data: AutoObserveCoverage | undefined, state: SourceState, locale: string, + now = Date.now(), ): CoverageSource => { const base = { busy: false, failure: null, detail: raw(""), stale: null, ...state }; if (!data) return { ...base, rows: [], totals: EMPTY_TOTALS, planned: new Set(), passMinutes: null }; const names = uniqueNames(data.rows); - const minutes = minutesSince(data.pass_at); + const minutes = minutesSince(data.pass_at, now); const rows = data.rows.map((row, i) => toRow(t, row, names[i], minutes, locale)); const planned = new Set(data.rows.flatMap((row, i) => (cannotWatch(row) ? [names[i]] : []))); return { ...base, rows, totals: totalsOf(data, rows), planned, passMinutes: minutes }; diff --git a/web/src/components/autoobserve/live/liveModel.spec.ts b/web/src/components/autoobserve/live/liveModel.spec.ts new file mode 100644 index 0000000000..e51f76048f --- /dev/null +++ b/web/src/components/autoobserve/live/liveModel.spec.ts @@ -0,0 +1,81 @@ +// Copyright 2026 OpenObserve Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { flushPromises, mount } from "@vue/test-utils"; +import { defineComponent, h, nextTick } from "vue"; +import i18n from "@/locales"; +import store from "@/test/unit/helpers/store"; +import autoobserve, { type AutoObserveCoverage } from "@/services/autoobserve"; +import { useLiveAutoObserve } from "./liveModel"; + +vi.mock("vue-router", async (importOriginal) => ({ + ...(await importOriginal>()), + useRoute: () => ({ query: { live: "1" } }), +})); + +vi.mock("@/services/autoobserve", () => ({ + default: { coverage: vi.fn(), suggestions: vi.fn() }, +})); + +const START = Date.UTC(2026, 10, 2, 10, 0); + +const cell = (state: string) => ({ state, evidence: [], counts: {} }); + +const coverage = (): AutoObserveCoverage => + ({ + view: "strict", + pass_at: START * 1000, + refreshed_at: null, + quality: null, + classes: [], + rows: [ + { + service_id: "api", + service_name: "api", + cluster: "prod", + namespace: "shop", + streams: [], + cells: { errors: cell("covered"), latency: cell("none"), absence: cell("none") }, + }, + ], + }) as unknown as AutoObserveCoverage; + +const Harness = defineComponent({ + setup() { + const { source } = useLiveAutoObserve(); + return () => h("span", { "data-test": "age" }, String(source.value.rows[0]?.lastScanned ?? "")); + }, +}); + +describe("useLiveAutoObserve", () => { + afterEach(() => vi.useRealTimers()); + + it("ages Last checked while the page stays open on the same data", async () => { + vi.useFakeTimers({ toFake: ["Date", "setInterval", "clearInterval"] }); + vi.setSystemTime(START); + vi.mocked(autoobserve.coverage).mockResolvedValue({ data: coverage() } as never); + vi.mocked(autoobserve.suggestions).mockResolvedValue({ + data: { suggestions: [], total: 0, limit: 5, offset: 0 }, + } as never); + const wrapper = mount(Harness, { global: { plugins: [i18n, store] } }); + await flushPromises(); + expect(wrapper.text()).toBe("just now"); + vi.advanceTimersByTime(2 * 60 * 60_000); + await nextTick(); + expect(wrapper.text()).toBe("2 hours ago"); + expect(autoobserve.coverage).toHaveBeenCalledTimes(1); + }); +}); diff --git a/web/src/components/autoobserve/live/liveModel.ts b/web/src/components/autoobserve/live/liveModel.ts index 21e5784ab6..66d9629f22 100644 --- a/web/src/components/autoobserve/live/liveModel.ts +++ b/web/src/components/autoobserve/live/liveModel.ts @@ -27,6 +27,7 @@ import { useRoute } from "vue-router"; import { useStore } from "vuex"; import { useQuery } from "@tanstack/vue-query"; import { useI18nTyped } from "@/types/i18n"; +import { useNow } from "@/composables/useNow"; import { autoobserveCoverageQuery, autoobserveSuggestionsPageQuery, @@ -168,6 +169,7 @@ export const useLiveAutoObserve = () => { const live = useLiveMode(); const { t } = useI18nTyped(); const zone = useLiveZone(); + const now = useNow(); const org = computed(() => store.state.selectedOrganization?.identifier ?? ""); const enabled = computed(() => live.value && !!org.value); const coverage = useQuery(() => @@ -187,8 +189,16 @@ export const useLiveAutoObserve = () => { () => stateOf(suggestions, retry), () => org.value, ); + // Ages are whole minutes, so the rows rebuild once a minute, not on every clock tick. + const minute = computed(() => Math.floor(now.value / 60_000)); const source = computed(() => - toCoverageSource(t, coverage.data.value, coverageState.value, zone.value.locale), + toCoverageSource( + t, + coverage.data.value, + coverageState.value, + zone.value.locale, + minute.value * 60_000, + ), ); const rail = computed(() => ({ ...railState.value,