Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion backend/actions/Model/getDocuments.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ const GetDocumentsParams = new Archetype({
fields: {
$type: 'string'
},
maxTimeMS: {
$type: 'number'
},
roles: {
$type: ['string']
}
Expand All @@ -45,7 +48,7 @@ module.exports = ({ db }) => async function getDocuments(params) {
const { roles } = params;
await authorize('Model.getDocuments', roles);

const { model, limit, skip, sortKey, sortDirection, searchText, fields } = params;
const { model, limit, skip, sortKey, sortDirection, searchText, fields, maxTimeMS } = params;

const Model = db.models[model];
if (Model == null) {
Expand All @@ -72,6 +75,9 @@ module.exports = ({ db }) => async function getDocuments(params) {
if (projection != null) {
query = query.select(projection);
}
if (typeof maxTimeMS === 'number' && maxTimeMS > 0) {
query = query.maxTimeMS(maxTimeMS);
}
const cursor = await query.cursor();
const docs = [];
for (let doc = await cursor.next(); doc != null; doc = await cursor.next()) {
Expand Down
8 changes: 7 additions & 1 deletion backend/actions/Model/getDocumentsStream.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ const GetDocumentsParams = new Archetype({
fields: {
$type: 'string'
},
maxTimeMS: {
$type: 'number'
},
roles: {
$type: ['string']
}
Expand All @@ -45,7 +48,7 @@ module.exports = ({ db }) => async function* getDocumentsStream(params) {
const { roles } = params;
await authorize('Model.getDocumentsStream', roles);

const { model, limit, skip, sortKey, sortDirection, searchText, fields } = params;
const { model, limit, skip, sortKey, sortDirection, searchText, fields, maxTimeMS } = params;

const Model = db.models[model];
if (Model == null) {
Expand All @@ -72,6 +75,9 @@ module.exports = ({ db }) => async function* getDocumentsStream(params) {
if (projection != null) {
query = query.select(projection);
}
if (typeof maxTimeMS === 'number' && maxTimeMS > 0) {
query = query.maxTimeMS(maxTimeMS);
}

const schemaPaths = {};
for (const path of Object.keys(Model.schema.paths)) {
Expand Down
43 changes: 43 additions & 0 deletions frontend/src/models/models.html
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,13 @@
>
{{ showRowNumbers ? 'Hide row numbers' : 'Show row numbers' }}
</button>
<button
@click="openQueryTimeoutModal"
type="button"
class="block w-full px-4 py-2 text-left text-sm text-content-secondary hover:bg-muted"
>
Query Timeout: {{ queryTimeoutSeconds }} {{ queryTimeoutSeconds === 1 ? 'second' : 'seconds' }}
</button>
<button
@click="resetFilter()"
type="button"
Expand Down Expand Up @@ -609,6 +616,42 @@ <h2>Are you sure you want to delete {{selectedDocuments.length}} documents?</h2>
</div>
</template>
</modal>
<modal v-if="shouldShowQueryTimeoutModal">
<template v-slot:body>
<div class="modal-exit" @click="shouldShowQueryTimeoutModal = false;">&times;</div>
<div class="text-xl font-bold mb-3">Query Timeout</div>
<p class="text-sm text-content-secondary mb-3">
Set the default timeout for document queries in this browser.
</p>
<label for="query-timeout-seconds" class="block text-sm font-medium text-content-secondary mb-1">
Timeout (seconds)
</label>
<input
id="query-timeout-seconds"
v-model="queryTimeoutDraftSeconds"
type="number"
min="1"
step="1"
class="w-full rounded border border-edge bg-surface px-2 py-1.5 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
<div class="mt-4 flex gap-2">
<button
type="button"
@click="shouldShowQueryTimeoutModal = false"
class="rounded bg-gray-400 px-2 py-2 text-sm font-semibold text-white shadow-sm hover:bg-page0 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-gray-500"
>
Cancel
</button>
<button
type="button"
@click="saveQueryTimeoutPreference"
class="rounded bg-primary px-2 py-2 text-sm font-semibold text-primary-text shadow-sm hover:bg-primary-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
>
Save
</button>
</div>
</template>
</modal>
<model-switcher
:show="showModelSwitcher"
:models="models"
Expand Down
47 changes: 46 additions & 1 deletion frontend/src/models/models.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ const DEFAULT_FIRST_N_FIELDS = 6;
const OUTPUT_TYPE_STORAGE_KEY = 'studio:model-output-type';
const SELECTED_GEO_FIELD_STORAGE_KEY = 'studio:model-selected-geo-field';
const SHOW_ROW_NUMBERS_STORAGE_KEY = 'studio:model-show-row-numbers';
const QUERY_TIMEOUT_SECONDS_STORAGE_KEY = 'studio:model-query-timeout-seconds';
const PROJECTION_MODE_QUERY_KEY = 'projectionMode';
const RECENTLY_VIEWED_MODELS_KEY = 'studio:recently-viewed-models';
const MAX_RECENT_MODELS = 4;
const DEFAULT_QUERY_TIMEOUT_SECONDS = 10;

/** Parse `fields` from the route (JSON array or inclusion projection object only). */
function parseFieldsQueryParam(fields) {
Expand Down Expand Up @@ -102,6 +104,7 @@ module.exports = app => app.component('models', {
shouldShowCollectionInfoModal: false,
shouldShowUpdateMultipleModal: false,
shouldShowDeleteMultipleModal: false,
shouldShowQueryTimeoutModal: false,
shouldExport: {},
sortBy: {},
query: {},
Expand All @@ -121,6 +124,8 @@ module.exports = app => app.component('models', {
recentlyViewedModels: [],
showModelSwitcher: false,
showRowNumbers: true,
queryTimeoutSeconds: DEFAULT_QUERY_TIMEOUT_SECONDS,
queryTimeoutDraftSeconds: String(DEFAULT_QUERY_TIMEOUT_SECONDS),
suppressScrollCheck: false,
scrollTopToRestore: null
}),
Expand All @@ -130,6 +135,7 @@ module.exports = app => app.component('models', {
this.loadOutputPreference();
this.loadSelectedGeoField();
this.loadShowRowNumbersPreference();
this.loadQueryTimeoutPreference();
this.loadRecentlyViewedModels();
this.isProjectionMenuSelected = this.$route?.query?.[PROJECTION_MODE_QUERY_KEY] === '1';
},
Expand Down Expand Up @@ -637,9 +643,47 @@ module.exports = app => app.component('models', {
if (fieldsParam) {
params.fields = fieldsParam;
}
params.maxTimeMS = this.getQueryTimeoutMS();

return params;
},
getQueryTimeoutMS() {
const parsedSeconds = Number(this.queryTimeoutSeconds);
const normalizedSeconds = Number.isFinite(parsedSeconds) && parsedSeconds > 0 ?
parsedSeconds :
DEFAULT_QUERY_TIMEOUT_SECONDS;
return Math.floor(normalizedSeconds * 1000);
},
loadQueryTimeoutPreference() {
if (typeof window === 'undefined' || !window.localStorage) {
return;
}
const stored = window.localStorage.getItem(QUERY_TIMEOUT_SECONDS_STORAGE_KEY);
const parsed = Number(stored);
if (Number.isFinite(parsed) && parsed > 0) {
this.queryTimeoutSeconds = parsed;
this.queryTimeoutDraftSeconds = String(parsed);
}
},
openQueryTimeoutModal() {
this.closeActionsMenu();
this.queryTimeoutDraftSeconds = String(this.queryTimeoutSeconds);
this.shouldShowQueryTimeoutModal = true;
},
saveQueryTimeoutPreference() {
const parsed = Number(this.queryTimeoutDraftSeconds);
if (!Number.isFinite(parsed) || parsed <= 0) {
this.$toast.error('Query timeout must be a positive number of seconds.');
return;
}
const normalized = Math.round(parsed);
this.queryTimeoutSeconds = normalized;
if (typeof window !== 'undefined' && window.localStorage) {
window.localStorage.setItem(QUERY_TIMEOUT_SECONDS_STORAGE_KEY, String(normalized));
}
this.shouldShowQueryTimeoutModal = false;
this.$toast.success('Query timeout updated.');
},
setSearchTextFromRoute() {
if (this.$route.query?.search) {
this.searchText = this.$route.query.search;
Expand Down Expand Up @@ -846,7 +890,8 @@ module.exports = app => app.component('models', {
model: this.currentModel,
limit: 1,
sortKey: '_id',
sortDirection: 1
sortDirection: 1,
maxTimeMS: this.getQueryTimeoutMS()
});
if (!Array.isArray(docs) || docs.length === 0) {
throw new Error('No documents found');
Expand Down
Loading