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,