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,