diff --git a/client/src/components/History/CurrentHistory/HistoryPanel.vue b/client/src/components/History/CurrentHistory/HistoryPanel.vue index 4e9fdc2a5a1d..32cf6c822d91 100644 --- a/client/src/components/History/CurrentHistory/HistoryPanel.vue +++ b/client/src/components/History/CurrentHistory/HistoryPanel.vue @@ -9,7 +9,7 @@ import SelectedItems from "@/components/History/Content/SelectedItems"; import { HistoryFilters } from "@/components/History/HistoryFilters"; import { deleteContent, updateContentFields } from "@/components/History/model/queries"; import { Toast } from "@/composables/toast"; -import { rewatchHistory } from "@/store/historyStore/model/watchHistory"; +import { startWatchingHistory } from "@/store/historyStore/model/watchHistory"; import { type HistoryItem, useHistoryItemsStore } from "@/stores/historyItemsStore"; import { useHistoryStore } from "@/stores/historyStore"; import { type Alias, getOperatorForAlias } from "@/utils/filtering"; @@ -277,7 +277,7 @@ async function onUnhide(item: HistoryItem) { } function reloadContents() { - rewatchHistory(); + startWatchingHistory(); } function setInvisible(item: HistoryItem) { diff --git a/client/src/components/History/adapters/HistoryPanelProxy.js b/client/src/components/History/adapters/HistoryPanelProxy.js index 1ad3b8f6f966..28b95b38627f 100644 --- a/client/src/components/History/adapters/HistoryPanelProxy.js +++ b/client/src/components/History/adapters/HistoryPanelProxy.js @@ -4,7 +4,7 @@ */ import Backbone from "backbone"; import { createDatasetCollection } from "components/History/model/queries"; -import { watchHistory } from "store/historyStore/model/watchHistory"; +import { startWatchingHistory } from "store/historyStore/model/watchHistory"; import { useHistoryItemsStore } from "stores/historyItemsStore"; import { useHistoryStore } from "stores/historyStore"; @@ -26,7 +26,7 @@ export class HistoryPanelProxy { }; // start watching the history with continuous queries - watchHistory(); + startWatchingHistory(); } syncCurrentHistoryModel(currentHistory) { diff --git a/client/src/components/InteractiveTools/InteractiveTools.vue b/client/src/components/InteractiveTools/InteractiveTools.vue index 007dea956f13..9cdf6d27c571 100644 --- a/client/src/components/InteractiveTools/InteractiveTools.vue +++ b/client/src/components/InteractiveTools/InteractiveTools.vue @@ -134,9 +134,8 @@ export default { this.load(); }, methods: { - ...mapActions(useEntryPointStore, ["ensurePollingEntryPoints", "removeEntryPoint"]), + ...mapActions(useEntryPointStore, ["removeEntryPoint"]), load() { - this.ensurePollingEntryPoints(); this.filter = ""; }, filtered: function (items) { diff --git a/client/src/components/Masthead/Masthead.vue b/client/src/components/Masthead/Masthead.vue index 21f44f39fe72..be4006a6e4bf 100644 --- a/client/src/components/Masthead/Masthead.vue +++ b/client/src/components/Masthead/Masthead.vue @@ -89,7 +89,7 @@ watch( /* lifecyle */ onBeforeMount(() => { entryPointStore = useEntryPointStore(); - entryPointStore.ensurePollingEntryPoints(); + entryPointStore.startWatchingEntryPoints(); entryPointStore.$subscribe((mutation, state) => { updateVisibility(state.entryPoints.length > 0); }); diff --git a/client/src/components/ToolEntryPoints/ToolEntryPoints.vue b/client/src/components/ToolEntryPoints/ToolEntryPoints.vue index ead83b63c419..e1e6525eac4f 100644 --- a/client/src/components/ToolEntryPoints/ToolEntryPoints.vue +++ b/client/src/components/ToolEntryPoints/ToolEntryPoints.vue @@ -41,7 +41,7 @@ import { library } from "@fortawesome/fontawesome-svg-core"; import { faExternalLinkAlt } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/vue-fontawesome"; import { getAppRoot } from "onload/loadConfig"; -import { mapActions, mapState } from "pinia"; +import { mapState } from "pinia"; import { useEntryPointStore } from "stores/entryPointStore"; library.add(faExternalLinkAlt); @@ -62,11 +62,5 @@ export default { return getAppRoot() + "interactivetool_entry_points/list"; }, }, - created: function () { - this.ensurePollingEntryPoints(); - }, - methods: { - ...mapActions(useEntryPointStore, ["ensurePollingEntryPoints"]), - }, }; diff --git a/client/src/composables/resourceWatcher.ts b/client/src/composables/resourceWatcher.ts new file mode 100644 index 000000000000..a5a36671421c --- /dev/null +++ b/client/src/composables/resourceWatcher.ts @@ -0,0 +1,94 @@ +export type WatchResourceHandler = () => Promise; + +export interface WatchOptions { + /** + * Polling interval in milliseconds when the app is active (in the current tab). + */ + shortPollingInterval?: number; + /** + * Polling interval in milliseconds when the app is in the background (not in the current tab). + */ + longPollingInterval?: number; + /** + * If true, the resource is watched in the background even when the app is not active (in the current tab). + */ + enableBackgroundPolling?: boolean; +} + +const DEFAULT_WATCH_OPTIONS: WatchOptions = { + shortPollingInterval: 3000, + longPollingInterval: 10000, + enableBackgroundPolling: true, +}; + +/** + * Creates a composable that watches a resource by polling the server continuously. + * By default, the polling interval is 'short' when the app is active (in the current tab) and 'long' + * when the app is in the background (not in the current tab). + * You can also completely disable background polling by setting `enableBackgroundPolling` to false in the options. + * @param watchHandler The handler function that watches the resource by querying the server. + * @param options Options to customize the polling interval. + */ +export function useResourceWatcher(watchHandler: WatchResourceHandler, options: WatchOptions = DEFAULT_WATCH_OPTIONS) { + const { shortPollingInterval, longPollingInterval, enableBackgroundPolling } = { + ...DEFAULT_WATCH_OPTIONS, + ...options, + }; + let currentPollingInterval = shortPollingInterval; + let watchTimeout: NodeJS.Timeout | null = null; + let isEventSetup = false; + + /** + * Starts watching the resource by polling the server continuously. + */ + function startWatchingResource() { + stopWatchingResource(); + tryWatchResource(); + } + + /** + * Stops continuously watching the resource. + */ + function stopWatchingResource() { + if (watchTimeout) { + clearTimeout(watchTimeout); + watchTimeout = null; + } + } + + async function tryWatchResource() { + try { + await watchHandler(); + } catch (error) { + console.warn(error); + } finally { + if (currentPollingInterval) { + watchTimeout = setTimeout(() => { + tryWatchResource(); + }, currentPollingInterval); + } + } + } + + function setupVisibilityListeners() { + if (!isEventSetup) { + isEventSetup = true; + document.addEventListener("visibilitychange", updateThrottle); + } + } + + function updateThrottle() { + if (document.visibilityState === "visible") { + currentPollingInterval = shortPollingInterval; + startWatchingResource(); + } else { + currentPollingInterval = enableBackgroundPolling ? longPollingInterval : undefined; + } + } + + setupVisibilityListeners(); + + return { + startWatchingResource, + }; +} diff --git a/client/src/entry/analysis/App.vue b/client/src/entry/analysis/App.vue index f9a04516a80a..4e260de84b8a 100644 --- a/client/src/entry/analysis/App.vue +++ b/client/src/entry/analysis/App.vue @@ -173,7 +173,7 @@ export default { this.Galaxy.modal = new Modal.View(); this.Galaxy.frame = this.windowManager; if (this.Galaxy.config.enable_notification_system) { - this.startNotificationsPolling(); + this.startWatchingNotifications(); } } }, @@ -189,9 +189,9 @@ export default { } }, methods: { - startNotificationsPolling() { + startWatchingNotifications() { const notificationsStore = useNotificationsStore(); - notificationsStore.startPollingNotifications(); + notificationsStore.startWatchingNotifications(); }, openUrl(urlObj) { if (!urlObj.target) { diff --git a/client/src/store/historyStore/model/watchHistory.js b/client/src/store/historyStore/model/watchHistory.js index 3d6a190a3a42..f04ceb582a69 100644 --- a/client/src/store/historyStore/model/watchHistory.js +++ b/client/src/store/historyStore/model/watchHistory.js @@ -13,13 +13,14 @@ import { getCurrentHistoryFromServer } from "stores/services/history.services"; import { loadSet } from "utils/setCache"; import { urlData } from "utils/url"; +import { useResourceWatcher } from "@/composables/resourceWatcher"; import { useCollectionElementsStore } from "@/stores/collectionElementsStore"; import { useDatasetStore } from "@/stores/datasetStore"; const limit = 1000; -let throttlePeriod = 3000; -let watchTimeout = null; +const ACTIVE_POLLING_INTERVAL = 3000; +const INACTIVE_POLLING_INTERVAL = 60000; // last time the history has changed let lastUpdateTime = null; @@ -27,17 +28,22 @@ let lastUpdateTime = null; // last time changed history items have been requested let lastRequestDate = new Date(); -// We only want to kick this off once we're actively watching history -let watchingVisibility = false; +const { startWatchingResource: startWatchingHistory } = useResourceWatcher(watchHistory, { + shortPollingInterval: ACTIVE_POLLING_INTERVAL, + longPollingInterval: INACTIVE_POLLING_INTERVAL, +}); -function setVisibilityThrottle() { - if (document.visibilityState === "visible") { - // Poll every 3 seconds when visible - throttlePeriod = 3000; - rewatchHistory(); - } else { - // Poll every 60 seconds when hidden/backgrounded - throttlePeriod = 60000; +export { startWatchingHistory }; + +async function watchHistory() { + const { isWatching } = storeToRefs(useHistoryItemsStore()); + try { + isWatching.value = true; + await watchHistoryOnce(); + } catch (error) { + // error alerting the user that watch history failed + console.warn(error); + isWatching.value = false; } } @@ -46,8 +52,7 @@ export async function watchHistoryOnce() { const historyItemsStore = useHistoryItemsStore(); const datasetStore = useDatasetStore(); const collectionElementsStore = useCollectionElementsStore(); - // "Reset" watchTimeout so we don't queue up watchHistory calls in rewatchHistory. - watchTimeout = null; + // get current history const checkForUpdate = new Date(); const history = await getCurrentHistoryFromServer(lastUpdateTime); @@ -96,35 +101,6 @@ export async function watchHistoryOnce() { } } -export async function watchHistory() { - const { isWatching } = storeToRefs(useHistoryItemsStore()); - // Only set up visibility listeners once, whenever a watch is first started - if (watchingVisibility === false) { - watchingVisibility = true; - isWatching.value = watchingVisibility; - document.addEventListener("visibilitychange", setVisibilityThrottle); - } - try { - await watchHistoryOnce(); - } catch (error) { - // error alerting the user that watch history failed - console.warn(error); - watchingVisibility = false; - isWatching.value = watchingVisibility; - } finally { - watchTimeout = setTimeout(() => { - watchHistory(); - }, throttlePeriod); - } -} - -export function rewatchHistory() { - if (watchTimeout) { - clearTimeout(watchTimeout); - watchHistory(); - } -} - /** * Returns the set of history item IDs that are currently expanded in the history panel from the cache. * These content items need to retrieve detailed information when updated. diff --git a/client/src/store/historyStore/model/watchHistory.test.js b/client/src/store/historyStore/model/watchHistory.test.js index b47213eddc19..e0984c7fe097 100644 --- a/client/src/store/historyStore/model/watchHistory.test.js +++ b/client/src/store/historyStore/model/watchHistory.test.js @@ -70,7 +70,7 @@ describe("watchHistory", () => { .replyOnce(200, historyData) .onGet(/api\/histories\/history-id\/contents?.*/) .replyOnce(200, historyItems); - await watchHistoryOnce(wrapper.vm.$store); + await watchHistoryOnce(); expect(wrapper.vm.getHistoryItems("history-id", "").length).toBe(2); expect(wrapper.vm.getHistoryItems("history-id", "second")[0].hid).toBe(2); expect(wrapper.vm.getHistoryItems("history-id", "state:ok")[0].hid).toBe(1); @@ -86,11 +86,11 @@ describe("watchHistory", () => { .onGet(`/history/current_history_json`) .replyOnce(500); - await watchHistoryOnce(wrapper.vm.$store); + await watchHistoryOnce(); expect(wrapper.vm.currentHistoryId).toBe("history-id"); expect(wrapper.vm.getHistoryItems("history-id", "").length).toBe(2); try { - await watchHistoryOnce(wrapper.vm.$store); + await watchHistoryOnce(); } catch (error) { console.log(error); expect(error.response.status).toBe(500); @@ -113,7 +113,7 @@ describe("watchHistory", () => { history_id: "history-id", }, ]); - await watchHistoryOnce(wrapper.vm.$store); + await watchHistoryOnce(); // We should have received the update and have 3 items in the history expect(wrapper.vm.getHistoryItems("history-id", "").length).toBe(3); }); diff --git a/client/src/stores/entryPointStore.test.js b/client/src/stores/entryPointStore.test.js index ddd9f8183f48..9c3aa5d2c08a 100644 --- a/client/src/stores/entryPointStore.test.js +++ b/client/src/stores/entryPointStore.test.js @@ -15,7 +15,7 @@ describe("stores/EntryPointStore", () => { setActivePinia(createPinia()); axiosMock.onGet("/api/entry_points", { params: { running: true } }).reply(200, testInteractiveToolsResponse); store = useEntryPointStore(); - store.ensurePollingEntryPoints(); + await store.fetchEntryPoints(); await flushPromises(); }); @@ -23,16 +23,7 @@ describe("stores/EntryPointStore", () => { axiosMock.restore(); }); - it("polls", async () => { - expect(store.entryPoints.length).toBe(2); - }); - it("stops polling", async () => { - expect(store.pollTimeout !== undefined).toBeTruthy(); - store.stopPollingEntryPoints(); - expect(store.pollTimeout === undefined).toBeTruthy(); - }); it("performs a partial update", async () => { - store.stopPollingEntryPoints(); const updateData = [ { model_class: "InteractiveToolEntryPoint", diff --git a/client/src/stores/entryPointStore.ts b/client/src/stores/entryPointStore.ts index 0c20bbcf84fc..e59b6fefeeae 100644 --- a/client/src/stores/entryPointStore.ts +++ b/client/src/stores/entryPointStore.ts @@ -3,9 +3,12 @@ import isEqual from "lodash.isequal"; import { defineStore } from "pinia"; import { computed, ref } from "vue"; +import { useResourceWatcher } from "@/composables/resourceWatcher"; import { getAppRoot } from "@/onload/loadConfig"; import { rethrowSimple } from "@/utils/simple-error"; +const ACTIVE_POLLING_INTERVAL = 10000; + // TODO: replace with the corresponding autogenerated model when ready interface EntryPoint { model_class: "InteractiveToolEntryPoint"; @@ -20,7 +23,11 @@ interface EntryPoint { } export const useEntryPointStore = defineStore("entryPointStore", () => { - const pollTimeout = ref(undefined); + const { startWatchingResource: startWatchingEntryPoints } = useResourceWatcher(fetchEntryPoints, { + shortPollingInterval: ACTIVE_POLLING_INTERVAL, + enableBackgroundPolling: false, // No need to poll in the background + }); + const entryPoints = ref([]); const entryPointsForJob = computed(() => { @@ -32,18 +39,6 @@ export const useEntryPointStore = defineStore("entryPointStore", () => { entryPoints.value.filter((entryPoint) => entryPoint["output_datasets_ids"].includes(hdaId)); }); - async function ensurePollingEntryPoints() { - await fetchEntryPoints(); - pollTimeout.value = setTimeout(() => { - ensurePollingEntryPoints(); - }, 10000); - } - - function stopPollingEntryPoints() { - clearTimeout(pollTimeout.value); - pollTimeout.value = undefined; - } - async function fetchEntryPoints() { const url = `${getAppRoot()}api/entry_points`; const params = { running: true }; @@ -96,8 +91,6 @@ export const useEntryPointStore = defineStore("entryPointStore", () => { fetchEntryPoints, updateEntryPoints, removeEntryPoint, - pollTimeout, - ensurePollingEntryPoints, - stopPollingEntryPoints, + startWatchingEntryPoints, }; }); diff --git a/client/src/stores/notificationsStore.ts b/client/src/stores/notificationsStore.ts index 589e9ce04c47..49a58eac7efc 100644 --- a/client/src/stores/notificationsStore.ts +++ b/client/src/stores/notificationsStore.ts @@ -7,19 +7,24 @@ import { loadNotificationsStatus, updateBatchNotificationsOnServer, } from "@/api/notifications"; +import { useResourceWatcher } from "@/composables/resourceWatcher"; import { mergeObjectListsById } from "@/utils/utils"; import { useBroadcastsStore } from "./broadcastsStore"; -const STATUS_POLLING_DELAY = 5000; +const ACTIVE_POLLING_INTERVAL = 5000; +const INACTIVE_POLLING_INTERVAL = 30000; export const useNotificationsStore = defineStore("notificationsStore", () => { + const { startWatchingResource: startWatchingNotifications } = useResourceWatcher(getNotificationStatus, { + shortPollingInterval: ACTIVE_POLLING_INTERVAL, + longPollingInterval: INACTIVE_POLLING_INTERVAL, + }); const broadcastsStore = useBroadcastsStore(); const totalUnreadCount = ref(0); const notifications = ref([]); - const pollId = ref(undefined); const loadingNotifications = ref(false); const lastNotificationUpdate = ref(null); @@ -31,7 +36,6 @@ export const useNotificationsStore = defineStore("notificationsStore", () => { } async function getNotificationStatus() { - stopPollingNotifications(); try { if (!lastNotificationUpdate.value) { loadingNotifications.value = true; @@ -56,22 +60,12 @@ export const useNotificationsStore = defineStore("notificationsStore", () => { } } - async function startPollingNotifications() { - await getNotificationStatus(); - pollId.value = setTimeout(() => startPollingNotifications(), STATUS_POLLING_DELAY); - } - - function stopPollingNotifications() { - clearTimeout(pollId.value); - pollId.value = undefined; - } - async function updateBatchNotification(request: UserNotificationsBatchUpdateRequest) { await updateBatchNotificationsOnServer(request); if (request.changes.deleted) { notifications.value = notifications.value.filter((n) => !request.notification_ids.includes(n.id)); } - await startPollingNotifications(); + startWatchingNotifications(); } async function updateNotification(notification: UserNotification, changes: NotificationChanges) { @@ -85,6 +79,6 @@ export const useNotificationsStore = defineStore("notificationsStore", () => { loadingNotifications, updateNotification, updateBatchNotification, - startPollingNotifications, + startWatchingNotifications, }; }); diff --git a/client/src/utils/data.js b/client/src/utils/data.js index 02431121f8ac..23e958193bae 100644 --- a/client/src/utils/data.js +++ b/client/src/utils/data.js @@ -4,7 +4,7 @@ import { FilesDialog } from "components/FilesDialog"; import { useGlobalUploadModal } from "composables/globalUploadModal"; import $ from "jquery"; import { getAppRoot } from "onload/loadConfig"; -import { rewatchHistory } from "store/historyStore/model/watchHistory"; +import { startWatchingHistory } from "store/historyStore/model/watchHistory"; import Vue from "vue"; import { uploadPayload } from "@/utils/upload-payload.js"; @@ -116,5 +116,5 @@ export function refreshContentsWrapper() { // Legacy Panel Interface. no-op if using new history Galaxy?.currHistoryPanel?.refreshContents(); // Will not do anything in legacy interface - rewatchHistory(); + startWatchingHistory(); }