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.
This commit is contained in:
Ashish Kolhe
2026-11-03 11:59:20 +05:30
parent 804c0df5d4
commit 3f6a7e18ba
3 changed files with 96 additions and 4 deletions
@@ -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 };
@@ -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 <http://www.gnu.org/licenses/>.
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<Record<string, unknown>>()),
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);
});
});
@@ -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<string>(() => 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<CoverageSource>(() =>
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<RailSource>(() => ({
...railState.value,