PR 11884, corrected several scrolling errors, made polling more efficient, improved empty history and no-query results message delivery

This commit is contained in:
Mason Houtz
2021-04-28 19:27:37 -07:00
parent afa477727c
commit 3c322a876d
20 changed files with 253 additions and 165 deletions
+3 -3
View File
@@ -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() {
+4 -11
View File
@@ -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
@@ -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)
);
};
+3
View File
@@ -0,0 +1,3 @@
export const clamp = (val, bottom, top) => {
return Math.max(bottom, Math.min(top, val));
};
+9 -8
View File
@@ -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)
);
});
};
+10
View File
@@ -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):