Skip to content

Commit 8fb30ce

Browse files
committed
Make history refresh button SSE-aware
The refresh button's title and red-when-stale logic was designed for the polling era and is misleading under SSE: lastCheckedTime only ticks on real history changes (so the "Last refreshed Xs ago" text describes nothing actionable), the 2-minute idle cutoff goes red even when SSE is healthy, and the click handler called the idempotent startWatchingHistory which short-circuits once initialized — clicking did nothing. In SSE mode the button now shows "Refresh history" with a normal link variant, and only turns red ("Live updates disconnected. Click to refresh.") when the SSE socket actually drops after a previous successful open (gated on the new sseEverConnected latch in useNotificationSSE so the brief initial-connect window isn't flagged as an outage). Clicking now forces a real refresh via refreshHistoryFromPush in both modes. Polling-mode title/variant is unchanged. Adds a HistoryCounter Vitest covering the SSE healthy / initial / lost transitions plus the polling-mode legacy behavior.
1 parent e8c16e6 commit 8fb30ce

5 files changed

Lines changed: 288 additions & 6 deletions

File tree

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
import { getLocalVue } from "@tests/vitest/helpers";
2+
import { shallowMount } from "@vue/test-utils";
3+
import flushPromises from "flush-promises";
4+
import { createPinia, setActivePinia } from "pinia";
5+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6+
7+
import type { RegisteredUser } from "@/api";
8+
import { useServerMock } from "@/api/client/__mocks__";
9+
import { setSseConnected, setSseHasEverConnected, sseMockFactory } from "@/stores/_testing/sseStoreSupport";
10+
import { useConfigStore } from "@/stores/configurationStore";
11+
import { useUserStore } from "@/stores/userStore";
12+
13+
import HistoryCounter from "./HistoryCounter.vue";
14+
15+
const sseState = vi.hoisted(() => ({
16+
onEvent: null as ((event: MessageEvent) => void) | null,
17+
connect: vi.fn(),
18+
disconnect: vi.fn(),
19+
}));
20+
21+
vi.mock("@/composables/useNotificationSSE", () => sseMockFactory(sseState));
22+
23+
// userStore wires its localStorage-backed refs through this composable; the
24+
// real watcher hits ``window.localStorage`` which jsdom doesn't expose with a
25+
// usable Storage prototype here. We don't read any of those refs in this
26+
// test, so a ref-returning stub is enough to keep userStore initialization
27+
// happy.
28+
vi.mock("@/composables/userLocalStorageFromHashedId", async () => {
29+
const { ref } = await import("vue");
30+
return {
31+
useUserLocalStorageFromHashId: <T>(_key: string, initialValue: T) => ref(initialValue),
32+
};
33+
});
34+
35+
const { server, http } = useServerMock();
36+
37+
const localVue = getLocalVue();
38+
39+
const baseHistory = {
40+
id: "hist-1",
41+
name: "Test history",
42+
user_id: "user-1",
43+
size: 0,
44+
contents_active: { active: 0, deleted: 0, hidden: 0 },
45+
update_time: new Date().toISOString(),
46+
create_time: new Date().toISOString(),
47+
deleted: false,
48+
archived: false,
49+
purged: false,
50+
published: false,
51+
};
52+
53+
function registerConfigHandler(enableSse: boolean): void {
54+
server.use(
55+
http.get("/api/configuration", ({ response }) => {
56+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
57+
return response(200).json({ enable_sse_updates: enableSse } as any);
58+
}),
59+
);
60+
}
61+
62+
function setEnableSse(enabled: boolean): void {
63+
registerConfigHandler(enabled);
64+
// The store kicks off ``loadConfig`` on creation; ``setConfiguration``
65+
// makes the value visible synchronously regardless of the network round
66+
// trip so the component reads it on mount.
67+
useConfigStore().setConfiguration({ enable_sse_updates: enabled } as never);
68+
// The refresh button is gated on ``currentUser``; without a logged-in
69+
// user the BButtonGroup that contains it is never rendered.
70+
useUserStore().currentUser = { id: "user-1", email: "u@example.com" } as RegisteredUser;
71+
}
72+
73+
function mountCounter(props: Partial<{ lastChecked: Date; isWatching: boolean }> = {}) {
74+
return shallowMount(HistoryCounter as unknown as object, {
75+
propsData: {
76+
history: baseHistory,
77+
lastChecked: props.lastChecked ?? new Date(),
78+
isWatching: props.isWatching ?? true,
79+
},
80+
localVue,
81+
});
82+
}
83+
84+
function refreshButton(wrapper: ReturnType<typeof shallowMount>) {
85+
return wrapper.get(".history-refresh-button");
86+
}
87+
88+
describe("HistoryCounter — refresh button", () => {
89+
beforeEach(() => {
90+
setActivePinia(createPinia());
91+
sseState.connect.mockClear();
92+
sseState.disconnect.mockClear();
93+
// sseMockFactory lazily creates these refs on first call; reset to a
94+
// known state for each test.
95+
Reflect.deleteProperty(sseState, "connected");
96+
Reflect.deleteProperty(sseState, "hasEverConnected");
97+
// Re-create refs by invoking the factory once — every component mount
98+
// already triggers this, but doing it explicitly makes the per-test
99+
// state setup obvious.
100+
sseMockFactory(sseState);
101+
vi.useFakeTimers();
102+
});
103+
104+
afterEach(() => {
105+
vi.useRealTimers();
106+
});
107+
108+
describe("SSE mode", () => {
109+
beforeEach(() => {
110+
setEnableSse(true);
111+
});
112+
113+
it('shows "Refresh history" with a link variant when the connection is healthy', async () => {
114+
setSseConnected(sseState, true);
115+
setSseHasEverConnected(sseState, true);
116+
117+
const wrapper = mountCounter();
118+
await flushPromises();
119+
120+
const button = refreshButton(wrapper);
121+
expect(button.attributes("title")).toBe("Refresh history");
122+
expect(button.attributes("variant")).toBe("link");
123+
});
124+
125+
it("does not flag the initial-connect window as a connection loss", async () => {
126+
// EventSource hasn't opened yet — connected=false, hasEverConnected=false.
127+
setSseConnected(sseState, false);
128+
setSseHasEverConnected(sseState, false);
129+
130+
const wrapper = mountCounter();
131+
await flushPromises();
132+
133+
const button = refreshButton(wrapper);
134+
expect(button.attributes("title")).toBe("Refresh history");
135+
expect(button.attributes("variant")).toBe("link");
136+
});
137+
138+
it("turns red when the SSE connection is lost after a successful open", async () => {
139+
setSseConnected(sseState, true);
140+
setSseHasEverConnected(sseState, true);
141+
142+
const wrapper = mountCounter();
143+
await flushPromises();
144+
145+
// Simulate the EventSource onerror path: connection drops after
146+
// it had previously been established.
147+
setSseConnected(sseState, false);
148+
await flushPromises();
149+
150+
const button = refreshButton(wrapper);
151+
expect(button.attributes("title")).toBe("Live updates disconnected. Click to refresh.");
152+
expect(button.attributes("variant")).toBe("danger");
153+
});
154+
});
155+
156+
describe("polling mode", () => {
157+
beforeEach(() => {
158+
setEnableSse(false);
159+
});
160+
161+
it("shows the legacy 'Last refreshed …' title with a link variant when fresh", async () => {
162+
const wrapper = mountCounter({ lastChecked: new Date(), isWatching: true });
163+
await flushPromises();
164+
165+
const button = refreshButton(wrapper);
166+
expect(button.attributes("title")).toMatch(/^Last refreshed .+ ago$/);
167+
expect(button.attributes("variant")).toBe("link");
168+
});
169+
170+
it("turns red after 2 minutes of staleness", async () => {
171+
// 3 minutes ago — past the 120000ms cutoff in HistoryCounter.
172+
const stale = new Date(Date.now() - 3 * 60 * 1000);
173+
const wrapper = mountCounter({ lastChecked: stale, isWatching: true });
174+
await flushPromises();
175+
176+
const button = refreshButton(wrapper);
177+
expect(button.attributes("title")).toMatch(/Consider reloading the page\.$/);
178+
expect(button.attributes("variant")).toBe("danger");
179+
});
180+
181+
it("turns red when the resource watcher reports it is no longer watching", async () => {
182+
const wrapper = mountCounter({ lastChecked: new Date(), isWatching: false });
183+
await flushPromises();
184+
185+
const button = refreshButton(wrapper);
186+
expect(button.attributes("variant")).toBe("danger");
187+
});
188+
});
189+
190+
it("emits reloadContents when the refresh button is clicked", async () => {
191+
setEnableSse(true);
192+
setSseConnected(sseState, true);
193+
setSseHasEverConnected(sseState, true);
194+
195+
const wrapper = mountCounter();
196+
await flushPromises();
197+
await refreshButton(wrapper).trigger("click");
198+
199+
expect(wrapper.emitted("reloadContents")).toBeTruthy();
200+
expect(wrapper.emitted("reloadContents")?.length).toBe(1);
201+
});
202+
});

client/src/components/History/CurrentHistory/HistoryCounter.vue

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,9 @@ import { useRouter } from "vue-router/composables";
1111
1212
import { type HistorySummaryExtended, userOwnsHistory } from "@/api";
1313
import { HistoryFilters } from "@/components/History/HistoryFilters.js";
14+
import { useConfig } from "@/composables/config";
1415
import { useHistoryContentStats } from "@/composables/historyContentStats";
16+
import { useSSEConnectionStatus } from "@/composables/useNotificationSSE";
1517
import { useUserStore } from "@/stores/userStore";
1618
import localize from "@/utils/localization";
1719
@@ -39,10 +41,18 @@ const emit = defineEmits(["update:filter-text", "reloadContents"]);
3941
4042
const router = useRouter();
4143
const { currentUser, isAnonymous } = storeToRefs(useUserStore());
44+
const { config } = useConfig();
45+
const { connected: sseConnected, hasEverConnected: sseHasEverConnected } = useSSEConnectionStatus();
4246
const { historySize, numItemsActive, numItemsDeleted, numItemsHidden } = useHistoryContentStats(
4347
toRef(props, "history"),
4448
);
4549
50+
const sseMode = computed(() => config.value?.enable_sse_updates === true);
51+
// Treat the connection as "lost" only after a successful open: the brief
52+
// initial-connect window where ``sseConnected`` is still false isn't a
53+
// real outage and shouldn't go red.
54+
const sseLost = computed(() => sseMode.value && sseHasEverConnected.value && !sseConnected.value);
55+
4656
const reloadButtonLoading = ref(false);
4757
const reloadButtonTitle = ref("");
4858
const reloadButtonVariant = ref("link");
@@ -84,6 +94,20 @@ function getCurrentFilterVal(filter: string) {
8494
}
8595
8696
function updateTime() {
97+
if (sseMode.value) {
98+
// Under SSE the "last checked" timestamp ticks only when the history
99+
// actually changes — a 2-minute idle window is normal and shouldn't
100+
// be presented as staleness. Surface a connection-lost warning
101+
// instead, gated on a previous successful open.
102+
if (sseLost.value) {
103+
reloadButtonTitle.value = "Live updates disconnected. Click to refresh.";
104+
reloadButtonVariant.value = "danger";
105+
} else {
106+
reloadButtonTitle.value = "Refresh history";
107+
reloadButtonVariant.value = "link";
108+
}
109+
return;
110+
}
87111
const diffToNow = formatDistanceToNowStrict(props.lastChecked, { addSuffix: true });
88112
const diffToNowSec = Date.now().valueOf() - props.lastChecked.valueOf();
89113
// if history isn't being watched or hasn't been watched/polled for over 2 minutes
@@ -104,9 +128,14 @@ async function reloadContents() {
104128
}, 1000);
105129
}
106130
131+
// Re-render the button as soon as the SSE state flips, instead of waiting up
132+
// to a second for the next setInterval tick — connection-loss feedback should
133+
// be immediate.
134+
watchImmediate([sseMode, sseLost], updateTime);
135+
107136
onMounted(() => {
108-
updateTime();
109-
// update every second
137+
// The polling-mode title is derived from a wall-clock diff that has no
138+
// reactive dependency, so a 1s tick keeps "Last refreshed Xs ago" fresh.
110139
setInterval(updateTime, 1000);
111140
});
112141
</script>

client/src/components/History/CurrentHistory/HistoryPanel.vue

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { storeToRefs } from "pinia";
44
import { computed, onMounted, ref, set as VueSet, unref, watch } from "vue";
55
66
import { type HistoryItemSummary, type HistorySummaryExtended, userOwnsHistory } from "@/api";
7+
import { getGalaxyInstance } from "@/app";
78
import ExpandedItems from "@/components/History/Content/ExpandedItems";
89
import { HistoryFilters } from "@/components/History/HistoryFilters";
910
import { deleteContent, updateContentFields } from "@/components/History/model/queries";
@@ -13,6 +14,7 @@ import { useHistoryStore } from "@/stores/historyStore";
1314
import { useUserStore } from "@/stores/userStore";
1415
import { type Alias, getOperatorForAlias } from "@/utils/filtering";
1516
import { setItemDragstart } from "@/utils/setDrag";
17+
import { refreshHistoryFromPush } from "@/watch/watchHistory";
1618
1719
import { useHistoryDragDrop } from "../../../composables/historyDragDrop";
1820
@@ -328,7 +330,11 @@ function updateContentStats() {
328330
}
329331
330332
function reloadContents() {
331-
historyStore.startWatchingHistory();
333+
// ``startWatchingHistory`` is idempotent, so the prior call did nothing
334+
// once SSE/polling was already initialized. Force a refresh through the
335+
// same code path SSE pushes use so the user-initiated click actually
336+
// re-fetches the history and items.
337+
refreshHistoryFromPush(getGalaxyInstance()).catch((err) => console.error("Manual history refresh failed:", err));
332338
}
333339
334340
function setInvisible(item: HistoryItemSummary) {

client/src/composables/useNotificationSSE.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { onScopeDispose, ref } from "vue";
1+
import { onScopeDispose, readonly, ref } from "vue";
22

33
import { withPrefix } from "@/utils/redirect";
44

@@ -39,6 +39,10 @@ type Handler = (event: MessageEvent) => void;
3939

4040
let sharedSource: EventSource | null = null;
4141
const sharedConnected = ref(false);
42+
// True once the SSE connection has succeeded at least once in this session.
43+
// Used by UI to distinguish "still connecting" from "was connected, dropped"
44+
// — only the latter should surface a connection-lost warning.
45+
const sseEverConnected = ref(false);
4246
const subscribers: Map<SSEEventType, Set<Handler>> = new Map();
4347
// Track the per-type dispatchers we registered so ``closeSource`` removes the
4448
// exact same listeners (``addEventListener`` matches by reference).
@@ -70,6 +74,7 @@ function openSourceIfNeeded() {
7074

7175
sharedSource.onopen = () => {
7276
sharedConnected.value = true;
77+
sseEverConnected.value = true;
7378
// Global readiness flag so Selenium tests can distinguish a working
7479
// SSE pipeline from the polling fallback.
7580
sseGlobals().__galaxy_sse_connected = true;
@@ -189,3 +194,16 @@ export function useSSE(onEvent: Handler, eventTypes: readonly SSEEventType[] = S
189194
* @deprecated Use `useSSE` instead. This alias exists for backward compatibility.
190195
*/
191196
export const useNotificationSSE = useSSE;
197+
198+
/**
199+
* Read-only handle on the shared SSE connection state. ``connected`` flips
200+
* with the EventSource lifecycle; ``hasEverConnected`` latches true on the
201+
* first successful open so callers can ignore the initial-connect window
202+
* when surfacing a "connection lost" warning.
203+
*/
204+
export function useSSEConnectionStatus() {
205+
return {
206+
connected: readonly(sharedConnected),
207+
hasEverConnected: readonly(sseEverConnected),
208+
};
209+
}

client/src/stores/_testing/sseStoreSupport.ts

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,20 +21,28 @@ export interface SSEMockState {
2121
connect: ReturnType<typeof vi.fn>;
2222
disconnect: ReturnType<typeof vi.fn>;
2323
connected?: Ref<boolean>;
24+
hasEverConnected?: Ref<boolean>;
2425
}
2526

2627
/** Build the factory used with ``vi.mock("@/composables/useNotificationSSE", ...)``. */
2728
export function sseMockFactory(state: SSEMockState) {
28-
// Lazily initialize ``connected`` so existing callers that don't pass it
29-
// still get a working ref.
29+
// Lazily initialize the connection refs so existing callers that don't
30+
// pre-populate them still get working refs.
3031
if (!state.connected) {
3132
state.connected = ref(false);
3233
}
34+
if (!state.hasEverConnected) {
35+
state.hasEverConnected = ref(false);
36+
}
3337
return {
3438
useSSE: vi.fn((onEvent: (event: MessageEvent) => void) => {
3539
state.onEvent = onEvent;
3640
return { connect: state.connect, disconnect: state.disconnect, connected: state.connected };
3741
}),
42+
useSSEConnectionStatus: vi.fn(() => ({
43+
connected: state.connected,
44+
hasEverConnected: state.hasEverConnected,
45+
})),
3846
};
3947
}
4048

@@ -46,6 +54,25 @@ export function emitSse(state: SSEMockState, type: string, payload: unknown): vo
4654
state.onEvent(new MessageEvent(type, { data: JSON.stringify(payload) }));
4755
}
4856

57+
/** Set the mocked ``connected`` flag, lazy-initializing the ref if a test reads
58+
* it before any component has mounted. */
59+
export function setSseConnected(state: SSEMockState, value: boolean): void {
60+
if (!state.connected) {
61+
state.connected = ref(value);
62+
} else {
63+
state.connected.value = value;
64+
}
65+
}
66+
67+
/** Set the mocked ``hasEverConnected`` latch, lazy-initializing the ref. */
68+
export function setSseHasEverConnected(state: SSEMockState, value: boolean): void {
69+
if (!state.hasEverConnected) {
70+
state.hasEverConnected = ref(value);
71+
} else {
72+
state.hasEverConnected.value = value;
73+
}
74+
}
75+
4976
/**
5077
* Save the current ``document.visibilityState`` descriptor and return a restorer.
5178
* Call the restorer in ``afterEach`` to prevent patching from leaking into later tests.

0 commit comments

Comments
 (0)