mirror of
https://github.com/galaxyproject/galaxy.git
synced 2026-09-24 16:30:27 +08:00
PR 11884, corrected several scrolling errors, made polling more efficient, improved empty history and no-query results message delivery
This commit is contained in:
@@ -40,7 +40,6 @@
|
||||
|
||||
<template v-slot:listcontrols>
|
||||
<ContentOperations
|
||||
v-if="!history.empty"
|
||||
:history="history"
|
||||
:total-matches="payload.totalMatches"
|
||||
:loading="loading"
|
||||
@@ -56,9 +55,10 @@
|
||||
</template>
|
||||
|
||||
<template v-slot:listing>
|
||||
<HistoryEmpty v-if="history.empty && payload.contents.length == 0" class="m-2" />
|
||||
<HistoryEmpty v-if="history.empty" class="m-2" />
|
||||
<HistoryEmpty v-else-if="payload && payload.noResults" message="No Results." class="m-2" />
|
||||
<Scroller
|
||||
v-else
|
||||
v-else-if="payload"
|
||||
:class="{ loadingBackground: loading }"
|
||||
key-field="hid"
|
||||
v-bind="payload"
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<template>
|
||||
<b-alert show>
|
||||
<h4>
|
||||
<h4 class="mb-1">
|
||||
<i class="fa fa-info-circle"></i>
|
||||
<span v-localize>This history is empty.</span>
|
||||
<span>{{ message | l }}</span>
|
||||
</h4>
|
||||
<p>
|
||||
<a href="#" @click.prevent="openGlobalUploadModal" v-localize>You can load your own data</a> or
|
||||
@@ -15,6 +15,9 @@
|
||||
import { openGlobalUploadModal } from "components/Upload";
|
||||
|
||||
export default {
|
||||
props: {
|
||||
message: { type: String, default: "This history is empty." },
|
||||
},
|
||||
methods: {
|
||||
openGlobalUploadModal,
|
||||
clickDataLink() {
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<script>
|
||||
import { debounce } from "lodash";
|
||||
import { SearchParams } from "./model";
|
||||
import { clamp } from "utils/math";
|
||||
|
||||
const clean = (o) => JSON.parse(JSON.stringify(o));
|
||||
|
||||
@@ -108,7 +109,7 @@ export default {
|
||||
|
||||
// need to dynamically calculate width
|
||||
scrollSliderContainerStyles() {
|
||||
const w = this.sbWidth;
|
||||
const w = this.sbWidth + 1;
|
||||
return { width: `${w}px` };
|
||||
},
|
||||
|
||||
@@ -182,19 +183,11 @@ export default {
|
||||
// no wheel if list too short to warrant a scroll bar
|
||||
// let the browser figure that out
|
||||
if (this.showScroller) {
|
||||
if (deltaY > 0) this.wheelDown();
|
||||
if (deltaY < 0) this.wheelUp();
|
||||
const n = deltaY == 0 ? 0 : Math.abs(deltaY) / deltaY;
|
||||
this.manualStartIndex = clamp(this.itemStartIndex + n, 0, this.totalMatches - 1);
|
||||
}
|
||||
},
|
||||
|
||||
wheelDown(n = 1) {
|
||||
this.manualStartIndex = Math.min(this.itemStartIndex + n, this.totalMatches - 1);
|
||||
},
|
||||
|
||||
wheelUp(n = 1) {
|
||||
this.manualStartIndex = Math.max(0, this.itemStartIndex - n);
|
||||
},
|
||||
|
||||
// move to whole new regions with the scrollbar, takes the percentage
|
||||
// of the height of the fake placeholder box and turns that into a 0-1
|
||||
// value which we send out on scrollPos
|
||||
|
||||
@@ -63,11 +63,16 @@ export const HistoryPanelProxy = Backbone.View.extend({
|
||||
});
|
||||
|
||||
// Watch the store, change the fake history model when it changs
|
||||
store.subscribe(({ type, payload: newId }) => {
|
||||
if (type == "betaHistory/setCurrentHistoryId") {
|
||||
Galaxy.currHistoryPanel.setModel(new History({ id: newId }));
|
||||
store.watch(
|
||||
(st, gets) => gets["betaHistory/currentHistory"],
|
||||
(history) => {
|
||||
const panel = Galaxy.currHistoryPanel;
|
||||
const existingId = panel?.model?.id || undefined;
|
||||
if (existingId != history.id) {
|
||||
panel.setModel(new History({ id: history.id }));
|
||||
}
|
||||
}
|
||||
});
|
||||
);
|
||||
},
|
||||
render() {
|
||||
// Hack: For now, remove unused "unified-panel" elements until we can
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { zip } from "rxjs";
|
||||
import { map, pluck, share } from "rxjs/operators";
|
||||
import { map, pluck, share, filter } from "rxjs/operators";
|
||||
import { hydrate } from "utils/observable";
|
||||
import { areDefined } from "utils/validation";
|
||||
import { requestWithUpdateTime } from "./operators/requestWithUpdateTime";
|
||||
import { prependPath } from "./workerConfig";
|
||||
import { bulkCacheContent } from "./db";
|
||||
@@ -30,27 +31,43 @@ export const loadHistoryContents = (cfg = {}) => (rawInputs$) => {
|
||||
hydrate([undefined, SearchParams]),
|
||||
);
|
||||
|
||||
const responseQualifier = ajaxResponse => ajaxResponse.status == 200 && ajaxResponse.response.length > 0;
|
||||
|
||||
const ajaxResponse$ = inputs$.pipe(
|
||||
map(([id, params, hid]) => {
|
||||
const baseUrl = `/api/histories/${id}/contents/near/${hid}/${windowSize}`;
|
||||
return `${baseUrl}?${params.historyContentQueryString}`;
|
||||
}),
|
||||
map(prependPath),
|
||||
requestWithUpdateTime({ dateStore, noInitial }),
|
||||
requestWithUpdateTime({ dateStore, noInitial, responseQualifier }),
|
||||
);
|
||||
|
||||
const validResponses$ = ajaxResponse$.pipe(
|
||||
filter(response => response.status == 200),
|
||||
share(),
|
||||
);
|
||||
|
||||
const cacheSummary$ = ajaxResponse$.pipe(
|
||||
const cacheSummary$ = validResponses$.pipe(
|
||||
pluck("response"),
|
||||
bulkCacheContent(),
|
||||
summarizeCacheOperation(),
|
||||
);
|
||||
|
||||
return zip(ajaxResponse$, cacheSummary$).pipe(
|
||||
return zip(validResponses$, cacheSummary$).pipe(
|
||||
map(([ajaxResponse, summary]) => {
|
||||
const { xhr, response = [] } = ajaxResponse;
|
||||
const { max: maxContentHid, min: minContentHid } = getPropRange(response, "hid");
|
||||
const headerInt = field => parseInt(xhr.getResponseHeader(field));
|
||||
|
||||
// return an int or undefined if the field does not exist in the headers
|
||||
const headerInt = field => {
|
||||
const raw = xhr.getResponseHeader(field);
|
||||
return raw === null ? undefined : parseInt(raw);
|
||||
};
|
||||
|
||||
const headerBool = field => {
|
||||
const raw = xhr.getResponseHeader(field);
|
||||
return (raw == "true" || raw == "1");
|
||||
}
|
||||
|
||||
// header counts
|
||||
const matchesUp = headerInt("matches_up");
|
||||
@@ -59,11 +76,16 @@ export const loadHistoryContents = (cfg = {}) => (rawInputs$) => {
|
||||
const totalMatchesDown = headerInt("total_matches_down");
|
||||
const minHid = headerInt("min_hid");
|
||||
const maxHid = headerInt("max_hid");
|
||||
const historySize = headerInt("history_size");
|
||||
const historyEmpty = headerBool("history_empty");
|
||||
|
||||
const matches = areDefined(matchesUp, matchesDown) ? matchesUp + matchesDown : undefined;
|
||||
const totalMatches = areDefined(totalMatchesUp, totalMatchesDown) ? totalMatchesUp + totalMatchesDown : undefined;
|
||||
|
||||
return {
|
||||
summary,
|
||||
matches: matchesUp + matchesDown,
|
||||
totalMatches: totalMatchesUp + totalMatchesDown,
|
||||
matches,
|
||||
totalMatches,
|
||||
minHid,
|
||||
maxHid,
|
||||
minContentHid, // minimum hid in the returned result
|
||||
@@ -73,6 +95,10 @@ export const loadHistoryContents = (cfg = {}) => (rawInputs$) => {
|
||||
matchesDown,
|
||||
totalMatchesUp,
|
||||
totalMatchesDown,
|
||||
|
||||
// new history size
|
||||
historySize,
|
||||
historyEmpty
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
@@ -21,10 +21,11 @@ export const requestWithUpdateTime = (config = {}) => {
|
||||
const {
|
||||
dateStore = requestDateStore,
|
||||
bufferSeconds = 0,
|
||||
dateFieldName = "update_time",
|
||||
dateFieldName = "update_time-gt",
|
||||
requestTime = moment.utc(),
|
||||
// indicates we don't want initial results
|
||||
noInitial = false
|
||||
noInitial = false,
|
||||
responseQualifier = () => true
|
||||
} = config;
|
||||
|
||||
// mark and flag this update-time, append to next request with same base
|
||||
@@ -37,7 +38,11 @@ export const requestWithUpdateTime = (config = {}) => {
|
||||
mergeMap((baseUrl) => of(baseUrl).pipe(
|
||||
appendUpdateTime({ dateStore, bufferSeconds, dateFieldName }),
|
||||
mergeMap(ajax),
|
||||
tap(() => dateStore.set(baseUrl, requestTime))
|
||||
tap((response) => {
|
||||
if (responseQualifier(response)) {
|
||||
dateStore.set(baseUrl, requestTime)
|
||||
}
|
||||
})
|
||||
))
|
||||
);
|
||||
};
|
||||
@@ -51,14 +56,14 @@ export const requestWithUpdateTime = (config = {}) => {
|
||||
const appendUpdateTime = (cfg = {}) => {
|
||||
const {
|
||||
dateStore = requestDateStore,
|
||||
dateFieldName = "update_time",
|
||||
dateFieldName = "update_time-gt",
|
||||
} = cfg;
|
||||
|
||||
return pipe(
|
||||
map((baseUrl) => {
|
||||
if (!dateStore.has(baseUrl)) return baseUrl;
|
||||
const lastRequest = dateStore.get(baseUrl);
|
||||
const parts = [baseUrl, `${dateFieldName}-gt=${lastRequest.toISOString()}`];
|
||||
const parts = [baseUrl, `${dateFieldName}=${lastRequest.toISOString()}`];
|
||||
const separator = baseUrl.includes("?") ? "&" : "?";
|
||||
return parts.join(separator);
|
||||
})
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { dateMixin, ModelBase } from "./ModelBase";
|
||||
import { bytesToString } from "utils/utils";
|
||||
|
||||
export class History extends dateMixin(ModelBase) {
|
||||
// not deleted
|
||||
@@ -18,14 +17,6 @@ export class History extends dateMixin(ModelBase) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
get niceSize() {
|
||||
return this.size ? bytesToString(this.size, true, 2) : "(empty)";
|
||||
}
|
||||
|
||||
get empty() {
|
||||
return this.size == 0;
|
||||
}
|
||||
|
||||
get statusDescription() {
|
||||
const status = [];
|
||||
if (this.shared) status.push("Shared");
|
||||
@@ -35,12 +26,6 @@ export class History extends dateMixin(ModelBase) {
|
||||
if (this.purged) status.push("Purged");
|
||||
return status.join(", ");
|
||||
}
|
||||
|
||||
loadProps(raw = {}) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const { empty, ...theRest } = raw;
|
||||
Object.assign(this, theRest);
|
||||
}
|
||||
}
|
||||
|
||||
History.equals = function (a, b) {
|
||||
|
||||
@@ -88,7 +88,7 @@ export const actions = {
|
||||
});
|
||||
}
|
||||
},
|
||||
loadHistoryById({ commit, getters }, id) {
|
||||
loadHistoryById({ commit, getters, dispatch }, id) {
|
||||
if (!promises.byId.has(id)) {
|
||||
// immediately set if we have something current
|
||||
const existing = getters.getHistoryById(id);
|
||||
@@ -97,12 +97,9 @@ export const actions = {
|
||||
}
|
||||
|
||||
// but also check for updates
|
||||
const lastUpdated = existing?.update_time || null;
|
||||
const p = getHistoryById(id, lastUpdated)
|
||||
const p = getHistoryById(id)
|
||||
.then((history) => {
|
||||
if (history.update_time !== lastUpdated) {
|
||||
commit("setHistory", history);
|
||||
}
|
||||
dispatch("selectHistory", history);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.warn("loadHistoryById error", id, err);
|
||||
@@ -114,8 +111,12 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
async setCurrentHistoryId({ dispatch, getters }, id) {
|
||||
const nextHistory = await setCurrentHistoryOnServer(id);
|
||||
dispatch("selectHistory", nextHistory);
|
||||
// Need to do 2 requests because apparently the response from "setHistory"
|
||||
// can't be twisted to be the same as a normal lookup
|
||||
if (id !== getters.currentHistoryId) {
|
||||
const changedHistory = await setCurrentHistoryOnServer(id);
|
||||
dispatch("loadHistoryById", changedHistory.id);
|
||||
}
|
||||
},
|
||||
async createNewHistory({ dispatch }) {
|
||||
// create history, then select it as current at the same time
|
||||
|
||||
@@ -81,7 +81,8 @@ const stdHistoryParams = {
|
||||
* Return list of available histories
|
||||
*/
|
||||
export async function getHistoryList() {
|
||||
const response = await api.get("/histories", { params: { view: "summary" } });
|
||||
const params = { view: "summary" };
|
||||
const response = await api.get("/histories", { params });
|
||||
return doResponse(response);
|
||||
}
|
||||
|
||||
@@ -117,8 +118,6 @@ export async function createNewHistory(props = {}) {
|
||||
return doResponse(createResponse);
|
||||
}
|
||||
|
||||
//localhost:8081/history/create_new_current
|
||||
|
||||
/**
|
||||
* Generates copy of history on server
|
||||
* @param {Object} history Source history
|
||||
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
import { of, Observable, BehaviorSubject, partition, merge } from "rxjs";
|
||||
import { of, Observable, Subject, partition, merge } from "rxjs";
|
||||
import { tap, map, pluck, switchMap, publish, distinctUntilChanged, share } from "rxjs/operators";
|
||||
import { chunk } from "utils/observable";
|
||||
import { SearchParams } from "../../model/SearchParams";
|
||||
@@ -12,7 +12,7 @@ export const collectionPayload = (cfg = {}) => {
|
||||
filters = new SearchParams(),
|
||||
pageSize = SearchParams.pageSize,
|
||||
debug = false,
|
||||
loading$ = new BehaviorSubject(),
|
||||
loadingEvents$ = new Subject(),
|
||||
} = cfg;
|
||||
|
||||
const { totalElements: totalMatches, contents_url } = dsc;
|
||||
@@ -48,9 +48,10 @@ export const collectionPayload = (cfg = {}) => {
|
||||
);
|
||||
|
||||
const serverLoad$ = pagination$.pipe(
|
||||
tap(() => loading$.next(true)),
|
||||
switchMap(pagination => of([contents_url, filters, pagination]).pipe(
|
||||
tap(() => loadingEvents$.next(true)),
|
||||
loadDscContent({ debug }),
|
||||
tap(() => loadingEvents$.next(false)),
|
||||
)),
|
||||
);
|
||||
|
||||
@@ -62,7 +63,6 @@ export const collectionPayload = (cfg = {}) => {
|
||||
const bottomRows = Math.max(0, totalMatches - contents.length - topRows);
|
||||
return { ...result, topRows, bottomRows, totalMatches };
|
||||
}),
|
||||
tap(() => loading$.next(false)),
|
||||
);
|
||||
|
||||
return new Observable((obs) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NEVER } from "rxjs";
|
||||
import { SearchParams, ScrollPos } from "../../model";
|
||||
import { isValidNumber } from "./helpers";
|
||||
import { isValidNumber } from "utils/validation";
|
||||
// import { reportPayload } from "../../test/providerTestHelpers";
|
||||
|
||||
// first emission, emitted when parent (history) or filters changes to reset the view
|
||||
@@ -71,7 +71,7 @@ export const ContentProvider = {
|
||||
|
||||
this.listenTo(scrolling$, (val) => (this.scrolling = val));
|
||||
this.listenTo(loading$, (val) => (this.loading = val));
|
||||
this.listenTo(resetPos$, (val) => this.resetScrollPos(val));
|
||||
this.listenTo(resetPos$, (pos) => this.setScrollPos(pos));
|
||||
|
||||
// render output
|
||||
this.listenTo(payload$, {
|
||||
@@ -82,8 +82,8 @@ export const ContentProvider = {
|
||||
},
|
||||
|
||||
methods: {
|
||||
resetScrollPos(pos = ScrollPos.create()) {
|
||||
this.scrollPos = pos;
|
||||
resetScrollPos() {
|
||||
this.setScrollPos(ScrollPos.create());
|
||||
},
|
||||
|
||||
initStreams() {
|
||||
|
||||
@@ -15,21 +15,6 @@ export const loadInputsSame = ([inputsA, cursorA], [inputsB, cursorB]) => {
|
||||
return cursorA == cursorB && inputsSame(inputsA, inputsB);
|
||||
};
|
||||
|
||||
// dumb math util
|
||||
export const clamp = (val, [bottom, top]) => {
|
||||
return Math.max(bottom, Math.min(top, val));
|
||||
};
|
||||
|
||||
// simple comparators
|
||||
export const isDefined = (val) => {
|
||||
return val !== null && val !== undefined;
|
||||
};
|
||||
|
||||
// defined, number and finite
|
||||
export const isValidNumber = (val) => {
|
||||
return isDefined(val) && !isNaN(val) && isFinite(val);
|
||||
};
|
||||
|
||||
export const paginationEqual = (a, b) => {
|
||||
return a.offset == b.offset && a.limit == b.limit;
|
||||
};
|
||||
|
||||
@@ -15,4 +15,4 @@ export { aggregateCacheUpdates } from "./aggregateCacheUpdates";
|
||||
export { processContentStreams } from "./processContentStreams";
|
||||
|
||||
// utils
|
||||
export { isValidNumber, paginationEqual } from "./helpers";
|
||||
export { paginationEqual } from "./helpers";
|
||||
|
||||
@@ -8,7 +8,7 @@ import { activity, whenAny, show } from "utils/observable";
|
||||
import { propMatch } from "./helpers";
|
||||
import { SearchParams, ScrollPos } from "../../model";
|
||||
import { defaultPayload } from "../ContentProvider";
|
||||
import { BehaviorSubject, Subject } from "rxjs";
|
||||
import { Subject } from "rxjs";
|
||||
|
||||
/**
|
||||
* Takes incoming history, filters and scroll position and generates loading, scrolling and payload
|
||||
@@ -43,7 +43,12 @@ export function processContentStreams(payloadOperator, sources = {}, settings =
|
||||
activity(200)
|
||||
);
|
||||
|
||||
const loading$ = new BehaviorSubject(false);
|
||||
const loadingEvents$ = new Subject();
|
||||
const loading$ = loadingEvents$.pipe(
|
||||
activity(debouncePeriod),
|
||||
startWith(true)
|
||||
);
|
||||
|
||||
const resetPos$ = new Subject();
|
||||
|
||||
// The actual loader, when history or params change we poll against the api and
|
||||
@@ -55,7 +60,7 @@ export function processContentStreams(payloadOperator, sources = {}, settings =
|
||||
console.clear();
|
||||
console.log("processContentStreams: pos", JSON.stringify(pos));
|
||||
}),
|
||||
payloadOperator({ parent, filters, loading$, resetPos$, ...settings }),
|
||||
payloadOperator({ parent, filters, loadingEvents$, resetPos$, ...settings }),
|
||||
startWith({ ...defaultPayload, placeholder: true }),
|
||||
)),
|
||||
share()
|
||||
|
||||
@@ -1,22 +1,28 @@
|
||||
import { merge, partition, Observable, BehaviorSubject, Subject } from "rxjs";
|
||||
import { concat, race, timer, merge, partition, Observable, BehaviorSubject, Subject } from "rxjs";
|
||||
import {
|
||||
tap,
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
filter,
|
||||
share,
|
||||
withLatestFrom,
|
||||
map,
|
||||
mapTo,
|
||||
mergeAll,
|
||||
pairwise,
|
||||
pluck,
|
||||
publish,
|
||||
mergeAll,
|
||||
debounceTime,
|
||||
share,
|
||||
shareReplay,
|
||||
take,
|
||||
tap,
|
||||
window,
|
||||
withLatestFrom,
|
||||
} from "rxjs/operators";
|
||||
import { chunk, show } from "utils/observable";
|
||||
import { isValidNumber } from "../ContentProvider";
|
||||
import { isValidNumber } from "utils/validation";
|
||||
import { SearchParams, CurveFit, ScrollPos } from "../../model";
|
||||
import { loadContents } from "./loadContents";
|
||||
import { watchHistoryContents } from "./watchHistoryContents";
|
||||
import { default as store } from "store/index";
|
||||
import { defaultPayload } from "../ContentProvider";
|
||||
|
||||
/**
|
||||
* Observable operator accepts history, search filters as config values, then uses an observable
|
||||
@@ -33,17 +39,20 @@ export const contentPayload = (cfg = {}) => {
|
||||
filters = new SearchParams(),
|
||||
pageSize = SearchParams.pageSize,
|
||||
debouncePeriod = 250,
|
||||
loadingTimeout = 2000,
|
||||
disablePoll = false,
|
||||
debug = false,
|
||||
// status outputs
|
||||
loading$ = new BehaviorSubject(),
|
||||
// optional feedback indicators
|
||||
loadingEvents$ = new Subject(),
|
||||
resetPos$ = new Subject(),
|
||||
} = cfg;
|
||||
|
||||
// stats for this history + filters, accumulates and improves over successive polls
|
||||
const totalMatches$ = new BehaviorSubject(0);
|
||||
const cursorToHid$ = new BehaviorSubject(createCursorToHid(history));
|
||||
const hidToTopRows$ = new BehaviorSubject(createHidToTopRows(history));
|
||||
// These running totals are shared between instances of content payload because a lot of the
|
||||
// stats do not get returned on every single pol. That means the most recent values need to be
|
||||
// preserved when the user switches the filters back and forth
|
||||
const totalMatches$ = getSubject("totalMatches", history, filters, () => new BehaviorSubject(history.hidItems));
|
||||
const cursorToHid$ = getSubject("cursorToHid", history, filters, () => new BehaviorSubject(createCursorToHid(history)));
|
||||
const hidToTopRows$ = getSubject("hidToTopRows", history, filters, () => new BehaviorSubject(createHidToTopRows(history)));
|
||||
|
||||
return publish((pos$) => {
|
||||
|
||||
@@ -82,8 +91,10 @@ export const contentPayload = (cfg = {}) => {
|
||||
);
|
||||
|
||||
const serverLoad$ = serverHid$.pipe(
|
||||
tap(() => loading$.next(true)),
|
||||
tap(() => loadingEvents$.next(true)),
|
||||
loadContents({ history, filters, disablePoll, debug }),
|
||||
tap(() => loadingEvents$.next(false)),
|
||||
tap((response) => updateVuexHistory(history.id, response)),
|
||||
share(),
|
||||
);
|
||||
|
||||
@@ -102,16 +113,23 @@ export const contentPayload = (cfg = {}) => {
|
||||
const cacheMonitor$ = cacheHid$.pipe(
|
||||
watchHistoryContents({ history, filters, pageSize, debouncePeriod, debug }),
|
||||
show(debug, (result) => console.log("cacheMonitor.contents (hid)", result.contents.map(o => o.hid))),
|
||||
share(),
|
||||
shareReplay(1)
|
||||
);
|
||||
|
||||
const payload$ = cacheMonitor$.pipe(
|
||||
const cachePayload$ = cacheMonitor$.pipe(
|
||||
withLatestFrom(totalMatches$, hidToTopRows$),
|
||||
map(buildPayload(pageSize)),
|
||||
tap(() => loading$.next(false)),
|
||||
show(debug, (payload) => console.log("payload", payload)),
|
||||
);
|
||||
|
||||
// An empty cache response, wait a while then emit an empty payload
|
||||
const noResults$ = timer(loadingTimeout).pipe(
|
||||
mapTo({ ...defaultPayload, noResults: true }),
|
||||
take(1)
|
||||
);
|
||||
const noInitialResults$ = concat(noResults$, cachePayload$);
|
||||
const payload$ = race(noInitialResults$, cachePayload$);
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Reposition View to accomodate recent updates
|
||||
@@ -122,19 +140,29 @@ export const contentPayload = (cfg = {}) => {
|
||||
// particular reason to shift the view. But that's not ideal when you're already looking at
|
||||
// the top of the history and expect to see the most recent updates as they come in.
|
||||
|
||||
const adjustedScrollPos$ = cacheMonitor$.pipe(
|
||||
pluck('contents'),
|
||||
mergeAll(),
|
||||
pluck('hid'),
|
||||
withLatestFrom(pos$, hidToTopRows$),
|
||||
filter(([hid, pos, fit]) => {
|
||||
const domainMax = Math.max(...fit.domain);
|
||||
const aboveTop = hid > domainMax;
|
||||
const scrollAtTop = pos.cursor !== undefined && pos.cursor !== null && pos.cursor == 0.0;
|
||||
return aboveTop && scrollAtTop;
|
||||
const adjustedScrollPos$ = serverLoad$.pipe(
|
||||
pairwise(),
|
||||
filter(([a,b]) => !isNaN(a.maxHid) && !isNaN(b.maxHid)),
|
||||
withLatestFrom(pos$, hid$),
|
||||
map(([[lastResponse, response], pos, hid]) => {
|
||||
const updatesAtTop = response.maxHid > lastResponse.maxHid;
|
||||
|
||||
const scrollerInDefault = (pos.cursor == 0 && pos.key == null);
|
||||
const fudge = 2;
|
||||
const scrollNearLastTop = Math.abs(hid - lastResponse.maxContentHid) < fudge;
|
||||
const scrollerAtTop = scrollerInDefault || scrollNearLastTop;
|
||||
|
||||
// console.group("should i reposition?");
|
||||
// console.log("updatesAtTop", updatesAtTop);
|
||||
// console.log("scrollerAtTop", scrollerAtTop);
|
||||
// console.groupEnd();
|
||||
|
||||
if (updatesAtTop && scrollerAtTop) {
|
||||
return new ScrollPos({ cursor: 0, key: response.maxHid })
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
map(([hid]) => ScrollPos.create({ key: hid })),
|
||||
debounceTime(debouncePeriod),
|
||||
filter(Boolean),
|
||||
);
|
||||
|
||||
// #endregion
|
||||
@@ -167,7 +195,7 @@ export const contentPayload = (cfg = {}) => {
|
||||
sub.add(newCursorToHid$.subscribe(cursorToHid$));
|
||||
sub.add(newHidToTopRows$.subscribe(hidToTopRows$));
|
||||
sub.add(newMatches$.subscribe(totalMatches$));
|
||||
sub.add(adjustedScrollPos$.subscribe(resetPos$))
|
||||
sub.add(adjustedScrollPos$.subscribe(resetPos$));
|
||||
|
||||
return sub;
|
||||
})
|
||||
@@ -188,7 +216,6 @@ const estimateHid = (cursor, fit) => {
|
||||
}
|
||||
|
||||
// we're screwed
|
||||
console.log("estimateHid no estimate available");
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -287,3 +314,36 @@ const createCursorToHid = (history) => {
|
||||
fit.set(1.0, 1);
|
||||
return fit;
|
||||
};
|
||||
|
||||
// Need to stash these subjects in memory since we don't get the counts
|
||||
// back on every single poll request
|
||||
|
||||
const subjects = new Map();
|
||||
|
||||
const getSubject = (label, history, filters, subjectFactory) => {
|
||||
const key = makeSubjectKey(label, history, filters);
|
||||
if (!subjects.has(key)) {
|
||||
subjects.set(key, subjectFactory());
|
||||
}
|
||||
return subjects.get(key);
|
||||
};
|
||||
|
||||
const makeSubjectKey = (label, history, filters) => {
|
||||
return JSON.stringify({ label, historyId: history.id, filters: filters.export() });
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the vuex history when a polling result comes back.
|
||||
* TODO: consider storing histories in cache instead of Vuex, since some users have very large lists
|
||||
* of histories.
|
||||
*
|
||||
* @param {string} historyId History id from poll result
|
||||
* @param {object} pollResponse Poll summary response
|
||||
*/
|
||||
const updateVuexHistory = (historyId, pollResponse) => {
|
||||
const getter = store.getters["betaHistory/getHistoryById"];
|
||||
const existingHistory = getter(historyId);
|
||||
const { historySize: size, historyEmpty: empty } = pollResponse;
|
||||
const history = { ...existingHistory, size, empty };
|
||||
store.dispatch("betaHistory/setHistory", history);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { concat, defer, of, throwError } from "rxjs";
|
||||
import { delay, repeat, switchMap, switchMapTo, debounceTime, startWith } from "rxjs/operators";
|
||||
import { defer, of, throwError } from "rxjs";
|
||||
import { switchMap, debounceTime, startWith, repeatWhen } from "rxjs/operators";
|
||||
import { decay } from "utils/observable";
|
||||
import { monitorXHR } from "utils/observable/monitorXHR";
|
||||
import { loadHistoryContents } from "../../caching";
|
||||
@@ -13,9 +13,7 @@ export const loadContents = (cfg = {}) => {
|
||||
initialInterval = 2 * 1000,
|
||||
maxInterval = 10 * initialInterval,
|
||||
disablePoll = false,
|
||||
// debug = false,
|
||||
// time between initial load and polling start
|
||||
startingDelay = 5000,
|
||||
debug = false,
|
||||
windowSize = 2 * SearchParams.pageSize,
|
||||
} = cfg;
|
||||
|
||||
@@ -28,39 +26,32 @@ export const loadContents = (cfg = {}) => {
|
||||
|
||||
const { id } = history;
|
||||
|
||||
const singleLoad = (hid) => defer(() => of([id, filters, hid]).pipe(
|
||||
loadHistoryContents({ windowSize }),
|
||||
));
|
||||
|
||||
const poll = (request$) => resetPoll(100).pipe(
|
||||
switchMap(() => request$.pipe(
|
||||
repeatWhen(completed$ => completed$.pipe(
|
||||
decay({ initialInterval, maxInterval, debug }),
|
||||
))
|
||||
))
|
||||
);
|
||||
|
||||
return switchMap((hid) => {
|
||||
|
||||
// a single history update
|
||||
const singleLoad$ = of([id, filters, hid]).pipe(
|
||||
loadHistoryContents({ windowSize })
|
||||
);
|
||||
|
||||
// start repeating, delay gets longer over time until unsubscribed
|
||||
const freshPoll$ = defer(() => singleLoad$.pipe(
|
||||
decay({ initialInterval, maxInterval }),
|
||||
repeat()
|
||||
));
|
||||
|
||||
// history, tools routes all refresh
|
||||
// exclude our own polling url though
|
||||
const routes = [/api\/(history|tools|histories)/];
|
||||
const methods = ["POST", "PUT", "DELETE"];
|
||||
const resetPoll$ = monitorXHR({ methods, routes });
|
||||
|
||||
// resets re-subscribe to freshPoll$ starting the decay over again
|
||||
const poll$ = resetPoll$.pipe(
|
||||
startWith(true),
|
||||
debounceTime(100),
|
||||
switchMapTo(freshPoll$)
|
||||
);
|
||||
|
||||
// if we're polling, add a little delay between initial load and polling
|
||||
// avoids pointless polling if the user is rapidly changing the view
|
||||
const polling$ = of(true).pipe(
|
||||
delay(startingDelay),
|
||||
switchMapTo(poll$)
|
||||
);
|
||||
|
||||
return disablePoll ? singleLoad$ : concat(singleLoad$, polling$);
|
||||
const request$ = singleLoad(hid);
|
||||
return disablePoll ? request$ : poll(request$);
|
||||
})
|
||||
};
|
||||
|
||||
// history, tools routes all refresh
|
||||
// prettier-ignore
|
||||
const resetPoll = (debouncePeriod) => {
|
||||
const routes = [/api\/(history|tools|histories)/];
|
||||
const methods = ["POST", "PUT", "DELETE"];
|
||||
|
||||
return monitorXHR({ methods, routes }).pipe(
|
||||
startWith(true),
|
||||
debounceTime(debouncePeriod)
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const clamp = (val, bottom, top) => {
|
||||
return Math.max(bottom, Math.min(top, val));
|
||||
};
|
||||
@@ -1,20 +1,21 @@
|
||||
// Stateful delay gets longer with each repeated call
|
||||
import { of } from "rxjs";
|
||||
import { mergeMap, delay } from "rxjs/operators";
|
||||
import { concatMap, delay } from "rxjs/operators";
|
||||
import { show } from "utils/observable";
|
||||
|
||||
export const decay = (cfg = {}) => {
|
||||
const { initialInterval, maxInterval = 60 * 1000, lambda = 0.25 } = cfg;
|
||||
|
||||
if (undefined === initialInterval) {
|
||||
throw new Error("provide an initialInterval to decay");
|
||||
}
|
||||
const { initialInterval = 1000, maxInterval = 60 * 1000, lambda = 0.25, debug = false } = cfg;
|
||||
|
||||
let counter = 0;
|
||||
|
||||
return mergeMap((val) => {
|
||||
return concatMap((val) => {
|
||||
let waitTime = Math.floor(initialInterval * Math.exp(lambda * counter++));
|
||||
waitTime = Math.max(waitTime, initialInterval);
|
||||
waitTime = Math.min(waitTime, maxInterval);
|
||||
return of(val).pipe(delay(waitTime));
|
||||
|
||||
return of(val).pipe(
|
||||
show(debug, (val) => console.log("decay", val, counter, waitTime)),
|
||||
delay(waitTime)
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// type utils
|
||||
|
||||
// all args not null and not undefined
|
||||
export const areDefined = (...vals) => vals.every(isDefined);
|
||||
|
||||
// not null and not undefined
|
||||
export const isDefined = (val) => val !== undefined && val !== null;
|
||||
|
||||
// defined, number and finite
|
||||
export const isValidNumber = (val) => isDefined(val) && !isNaN(val) && isFinite(val);
|
||||
@@ -1,9 +1,11 @@
|
||||
"""
|
||||
API operations on the contents of a history.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from galaxy import (
|
||||
exceptions,
|
||||
@@ -1057,7 +1059,7 @@ class HistoryContentsController(BaseGalaxyAPIController, UsesLibraryMixinItems,
|
||||
trans.response.headers.update(archive.get_headers())
|
||||
return archive.response()
|
||||
|
||||
@expose_api_anonymous
|
||||
@expose_api_raw_anonymous
|
||||
def contents_near(self, trans, history_id, hid, limit, **kwd):
|
||||
"""
|
||||
This endpoint provides random access to a large history without having
|
||||
@@ -1075,6 +1077,18 @@ class HistoryContentsController(BaseGalaxyAPIController, UsesLibraryMixinItems,
|
||||
* GET /api/histories/{history_id}/contents/near/{hid}/{limit}
|
||||
"""
|
||||
history = self.history_manager.get_accessible(self.decode_id(history_id), trans.user, current_history=trans.history)
|
||||
|
||||
# while polling, check to see if the history has changed
|
||||
# if it hasn't then we can short-circuit the poll request
|
||||
since = kwd.get('update_time-gt', None)
|
||||
if since:
|
||||
since_str = self.history_contents_filters.parse_date(since)
|
||||
since_date = datetime.fromisoformat(since_str)
|
||||
if history.update_time <= since_date:
|
||||
trans.response.status = 204
|
||||
return
|
||||
|
||||
# parse content params
|
||||
filter_params = self._parse_rest_params(kwd)
|
||||
serialization_params = self._parse_serialization_params(kwd, 'betawebclient')
|
||||
view = serialization_params.pop('view')
|
||||
@@ -1105,8 +1119,10 @@ class HistoryContentsController(BaseGalaxyAPIController, UsesLibraryMixinItems,
|
||||
trans.response.headers['total_matches_down'] = down_count
|
||||
trans.response.headers['max_hid'] = max_hid
|
||||
trans.response.headers['min_hid'] = min_hid
|
||||
trans.response.headers['history_size'] = str(history.disk_size)
|
||||
trans.response.headers['history_empty'] = json.dumps(history.empty) # convert to proper bool
|
||||
|
||||
return contents
|
||||
return json.dumps(contents)
|
||||
|
||||
# Perform content query and matching count
|
||||
def _seek(self, history, filter_params, order_by_string, limit, serialization_params):
|
||||
|
||||
Reference in New Issue
Block a user