From a6ffa6096a755e609a4f91239840106fb3524d88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Sadzi=C5=84ski?= Date: Tue, 21 May 2024 18:27:06 +0200 Subject: [PATCH] (core) Adding UI for timing API Summary: Adding new buttons to control the `timing` API and a way to view the results using virtual table features. Test Plan: Added new Reviewers: georgegevoian Reviewed By: georgegevoian Subscribers: paulfitz Differential Revision: https://phab.getgrist.com/D4252 --- app/client/components/Forms/styles.ts | 9 +- app/client/components/GridView.js | 17 +- app/client/components/GristDoc.ts | 25 +- app/client/models/ClientColumnGetters.ts | 4 +- app/client/models/entities/ViewSectionRec.ts | 2 +- app/client/ui/AdminPanelCss.ts | 5 +- app/client/ui/DocumentSettings.ts | 182 ++++++++++++-- app/client/ui/TimingPage.ts | 252 +++++++++++++++++++ app/client/ui/WebhookPage.ts | 25 +- app/client/ui2018/modals.ts | 2 +- app/common/ActiveDocAPI.ts | 7 +- app/common/CommTypes.ts | 3 + app/common/DocListAPI.ts | 1 + app/common/SortSpec.ts | 73 ++++-- app/common/UserAPI.ts | 17 +- app/common/gristUrls.ts | 2 +- app/server/lib/ActiveDoc.ts | 17 ++ app/server/lib/DocManager.ts | 3 +- app/server/lib/Export.ts | 4 + app/server/lib/ServerColumnGetters.ts | 4 + sandbox/grist/main.py | 2 +- test/client/lib/sortUtil.ts | 17 +- test/nbrowser/CopyPaste.ts | 2 +- test/nbrowser/NumericEditor.ts | 4 +- test/nbrowser/Timing.ts | 196 +++++++++++++++ test/server/lib/DocApi.ts | 32 ++- test/server/lib/DocApi2.ts | 3 +- test/server/lib/Webhooks-Proxy.ts | 2 +- test/server/lib/helpers/Signal.ts | 90 ++++--- 29 files changed, 858 insertions(+), 144 deletions(-) create mode 100644 app/client/ui/TimingPage.ts create mode 100644 test/nbrowser/Timing.ts diff --git a/app/client/components/Forms/styles.ts b/app/client/components/Forms/styles.ts index 49181532..1288d090 100644 --- a/app/client/components/Forms/styles.ts +++ b/app/client/components/Forms/styles.ts @@ -1,6 +1,6 @@ import {textarea} from 'app/client/ui/inputs'; import {sanitizeHTML} from 'app/client/ui/sanitizeHTML'; -import {basicButton, basicButtonLink, textButton} from 'app/client/ui2018/buttons'; +import {basicButton, basicButtonLink, primaryButtonLink, textButton} from 'app/client/ui2018/buttons'; import {cssLabel} from 'app/client/ui2018/checkbox'; import {colors, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; @@ -403,7 +403,7 @@ export const cssSmallLinkButton = styled(basicButtonLink, ` min-height: 26px; `); -export const cssSmallButton = styled(basicButton, ` +const textSmallButton = ` display: flex; align-items: center; gap: 4px; @@ -423,7 +423,10 @@ export const cssSmallButton = styled(basicButton, ` background-color: #B8791B; border: none; } -`); +`; + +export const cssSmallButton = styled(basicButton, textSmallButton); +export const cssPrimarySmallLink = styled(primaryButtonLink, textSmallButton); export const cssMarkdownRendered = styled('div', ` min-height: 1.5rem; diff --git a/app/client/components/GridView.js b/app/client/components/GridView.js index 184f7375..8d8c6acc 100644 --- a/app/client/components/GridView.js +++ b/app/client/components/GridView.js @@ -79,6 +79,7 @@ function GridView(gristDoc, viewSectionModel, isPreview = false) { BaseView.call(this, gristDoc, viewSectionModel, { isPreview, 'addNewRow': true }); this.viewSection = viewSectionModel; + this.isReadonly = this.gristDoc.isReadonly.get() || this.viewSection.isVirtual(); //-------------------------------------------------- // Observables local to this view @@ -390,7 +391,7 @@ GridView.gridCommands = { if (!action) { return; } // if grist document is in readonly - simply change the value // without saving - if (this.gristDoc.isReadonly.get()) { + if (this.isReadonly) { this.viewSection.rawNumFrozen(action.numFrozen); return; } @@ -1270,7 +1271,7 @@ GridView.prototype.buildDom = function() { const isEditingLabel = koUtil.withKoUtils(ko.pureComputed({ read: () => { const goodIndex = () => editIndex() === field._index(); - const isReadonly = () => this.gristDoc.isReadonlyKo() || self.isPreview; + const isReadonly = () => this.isReadonly || self.isPreview; const isSummary = () => Boolean(field.column().disableEditData()); return goodIndex() && !isReadonly() && !isSummary(); }, @@ -1335,7 +1336,7 @@ GridView.prototype.buildDom = function() { }, kd.style('width', field.widthPx), kd.style('borderRightWidth', v.borderWidthPx), - viewCommon.makeResizable(field.width, {shouldSave: !this.gristDoc.isReadonly.get()}), + viewCommon.makeResizable(field.width, {shouldSave: !this.isReadonly}), kd.toggleClass('selected', () => ko.unwrap(this.isColSelected.at(field._index()))), dom.on('contextmenu', ev => { // This is a little hack to position the menu the same way as with a click @@ -1382,7 +1383,7 @@ GridView.prototype.buildDom = function() { this._buildInsertColumnMenu({field}), ); }), - this.isPreview ? null : kd.maybe(() => !this.gristDoc.isReadonlyKo(), () => ( + this.isPreview ? null : kd.maybe(() => !this.isReadonly, () => ( this._modField = dom('div.column_name.mod-add-column.field', '+', kd.style("width", PLUS_WIDTH + 'px'), @@ -1933,7 +1934,7 @@ GridView.prototype._getColumnMenuOptions = function(copySelection) { numColumns: copySelection.fields.length, numFrozen: this.viewSection.numFrozen.peek(), disableModify: calcFieldsCondition(copySelection.fields, f => f.disableModify.peek()), - isReadonly: this.gristDoc.isReadonly.get() || this.isPreview, + isReadonly: this.isReadonly || this.isPreview, isRaw: this.viewSection.isRaw(), isFiltered: this.isFiltered(), isFormula: calcFieldsCondition(copySelection.fields, f => f.column.peek().isRealFormula.peek()), @@ -1999,17 +2000,17 @@ GridView.prototype.cellContextMenu = function() { GridView.prototype._getCellContextMenuOptions = function() { return { disableInsert: Boolean( - this.gristDoc.isReadonly.get() || + this.isReadonly || this.viewSection.disableAddRemoveRows() || this.tableModel.tableMetaRow.onDemand() ), disableDelete: Boolean( - this.gristDoc.isReadonly.get() || + this.isReadonly || this.viewSection.disableAddRemoveRows() || this.getSelection().onlyAddRowSelected() ), disableMakeHeadersFromRow: Boolean( - this.gristDoc.isReadonly.get() || + this.isReadonly || this.getSelection().rowIds.length !== 1 || this.getSelection().onlyAddRowSelected() || this.viewSection.table().summarySourceTable() !== 0 diff --git a/app/client/components/GristDoc.ts b/app/client/components/GristDoc.ts index bbbab48b..5c7f2f7f 100644 --- a/app/client/components/GristDoc.ts +++ b/app/client/components/GristDoc.ts @@ -49,6 +49,7 @@ import {DocSettingsPage} from 'app/client/ui/DocumentSettings'; import {isTourActive, isTourActiveObs} from "app/client/ui/OnBoardingPopups"; import {DefaultPageWidget, IPageWidget, toPageWidget} from 'app/client/ui/PageWidgetPicker'; import {linkFromId, NoLink, selectBy} from 'app/client/ui/selectBy'; +import {TimingPage} from 'app/client/ui/TimingPage'; import {WebhookPage} from 'app/client/ui/WebhookPage'; import {startWelcomeTour} from 'app/client/ui/WelcomeTour'; import {getTelemetryWidgetTypeFromPageWidget} from 'app/client/ui/widgetTypesMap'; @@ -196,6 +197,8 @@ export class GristDoc extends DisposableWithEvents { return this.docPageModel.appModel.api.getDocAPI(this.docPageModel.currentDocId.get()!); } + public isTimingOn = Observable.create(this, false); + private _actionLog: ActionLog; private _undoStack: UndoStack; private _lastOwnActionGroup: ActionGroupWithCursorPos | null = null; @@ -228,6 +231,7 @@ export class GristDoc extends DisposableWithEvents { ) { super(); console.log("RECEIVED DOC RESPONSE", openDocResponse); + this.isTimingOn.set(openDocResponse.isTimingOn); this.docData = new DocData(this.docComm, openDocResponse.doc); this.docModel = new DocModel(this.docData, this.docPageModel); this.querySetManager = QuerySetManager.create(this, this.docModel, this.docComm); @@ -635,6 +639,7 @@ export class GristDoc extends DisposableWithEvents { content === 'data' ? dom.create(RawDataPage, this) : content === 'settings' ? dom.create(DocSettingsPage, this) : content === 'webhook' ? dom.create(WebhookPage, this) : + content === 'timing' ? dom.create(TimingPage, this) : content === 'GristDocTour' ? null : [ dom.create((owner) => { @@ -842,16 +847,20 @@ export class GristDoc extends DisposableWithEvents { } public onDocChatter(message: CommDocChatter) { - if (!this.docComm.isActionFromThisDoc(message) || - !message.data.webhooks) { + if (!this.docComm.isActionFromThisDoc(message)) { return; } - if (message.data.webhooks.type == 'webhookOverflowError') { - this.trigger('webhookOverflowError', - t('New changes are temporarily suspended. Webhooks queue overflowed.' + - ' Please check webhooks settings, remove invalid webhooks, and clean the queue.'),); - } else { - this.trigger('webhooks', message.data.webhooks); + + if (message.data.webhooks) { + if (message.data.webhooks.type == 'webhookOverflowError') { + this.trigger('webhookOverflowError', + t('New changes are temporarily suspended. Webhooks queue overflowed.' + + ' Please check webhooks settings, remove invalid webhooks, and clean the queue.'),); + } else { + this.trigger('webhooks', message.data.webhooks); + } + } else if (message.data.timing) { + this.isTimingOn.set(message.data.timing.status !== 'disabled'); } } diff --git a/app/client/models/ClientColumnGetters.ts b/app/client/models/ClientColumnGetters.ts index ba445900..63f744a4 100644 --- a/app/client/models/ClientColumnGetters.ts +++ b/app/client/models/ClientColumnGetters.ts @@ -22,7 +22,9 @@ export class ClientColumnGetters implements ColumnGetters { } public getColGetter(colSpec: Sort.ColSpec): ColumnGetter | null { - const rowModel = this._tableModel.docModel.columns.getRowModel(Sort.getColRef(colSpec)); + const rowModel = this._tableModel.docModel.columns.getRowModel( + Sort.getColRef(colSpec) as number /* HACK: for virtual tables */ + ); const colId = rowModel.colId(); let getter: ColumnGetter|undefined = this._tableModel.tableData.getRowPropFunc(colId); if (!getter) { return null; } diff --git a/app/client/models/entities/ViewSectionRec.ts b/app/client/models/entities/ViewSectionRec.ts index 82eb2760..bbbbe80e 100644 --- a/app/client/models/entities/ViewSectionRec.ts +++ b/app/client/models/entities/ViewSectionRec.ts @@ -675,7 +675,7 @@ export function createViewSectionRec(this: ViewSectionRec, docModel: DocModel): // with sharing. this.activeSortSpec = modelUtil.jsonObservable(this.activeSortJson, (obj: Sort.SortSpec|null) => { return (obj || []).filter((sortRef: Sort.ColSpec) => { - const colModel = docModel.columns.getRowModel(Sort.getColRef(sortRef)); + const colModel = docModel.columns.getRowModel(Sort.getColRef(sortRef) as number /* HACK: for virtual tables */); return !colModel._isDeleted() && colModel.getRowId(); }); }); diff --git a/app/client/ui/AdminPanelCss.ts b/app/client/ui/AdminPanelCss.ts index 122ff395..d773c1ce 100644 --- a/app/client/ui/AdminPanelCss.ts +++ b/app/client/ui/AdminPanelCss.ts @@ -120,7 +120,7 @@ const cssItemShort = styled('div', ` `); const cssItemName = styled('div', ` - width: 136px; + width: 150px; font-weight: bold; display: flex; align-items: center; @@ -148,6 +148,7 @@ const cssItemName = styled('div', ` const cssItemDescription = styled('div', ` margin-right: auto; + margin-bottom: -1px; /* aligns with the value */ `); const cssItemValue = styled('div', ` @@ -173,7 +174,7 @@ const cssExpandedContentWrap = styled('div', ` const cssExpandedContent = styled('div', ` margin-left: 24px; - padding: 24px 0; + padding: 18px 0; border-bottom: 1px solid ${theme.widgetBorder}; .${cssItem.className}:last-child & { padding-bottom: 0; diff --git a/app/client/ui/DocumentSettings.ts b/app/client/ui/DocumentSettings.ts index 71fe47c2..615292e7 100644 --- a/app/client/ui/DocumentSettings.ts +++ b/app/client/ui/DocumentSettings.ts @@ -2,7 +2,7 @@ * This module export a component for editing some document settings consisting of the timezone, * (new settings to be added here ...). */ -import {cssSmallButton, cssSmallLinkButton} from 'app/client/components/Forms/styles'; +import {cssPrimarySmallLink, cssSmallButton, cssSmallLinkButton} from 'app/client/components/Forms/styles'; import {GristDoc} from 'app/client/components/GristDoc'; import {ACIndexImpl} from 'app/client/lib/ACIndex'; import {ACSelectItem, buildACSelect} from 'app/client/lib/ACSelect'; @@ -14,21 +14,25 @@ import {urlState} from 'app/client/models/gristUrlState'; import {KoSaveableObservable} from 'app/client/models/modelUtil'; import {AdminSection, AdminSectionItem} from 'app/client/ui/AdminPanelCss'; import {hoverTooltip, showTransientTooltip} from 'app/client/ui/tooltips'; -import {colors, mediaSmall, testId, theme} from 'app/client/ui2018/cssVars'; +import {bigBasicButton, bigPrimaryButton} from 'app/client/ui2018/buttons'; +import {cssRadioCheckboxOptions, radioCheckboxOption} from 'app/client/ui2018/checkbox'; +import {colors, mediaSmall, theme} from 'app/client/ui2018/cssVars'; import {icon} from 'app/client/ui2018/icons'; import {cssLink} from 'app/client/ui2018/links'; +import {loadingSpinner} from 'app/client/ui2018/loaders'; import {select} from 'app/client/ui2018/menus'; -import {confirmModal} from 'app/client/ui2018/modals'; +import {confirmModal, cssModalButtons, cssModalTitle, cssSpinner, modal} from 'app/client/ui2018/modals'; import {buildCurrencyPicker} from 'app/client/widgets/CurrencyPicker'; import {buildTZAutocomplete} from 'app/client/widgets/TZAutocomplete'; import {EngineCode} from 'app/common/DocumentSettings'; import {commonUrls, GristLoadConfig} from 'app/common/gristUrls'; -import {propertyCompare} from 'app/common/gutil'; +import {not, propertyCompare} from 'app/common/gutil'; import {getCurrency, locales} from 'app/common/Locales'; -import {Computed, Disposable, dom, fromKo, IDisposableOwner, styled} from 'grainjs'; +import {Computed, Disposable, dom, fromKo, IDisposableOwner, makeTestId, Observable, styled} from 'grainjs'; import * as moment from 'moment-timezone'; const t = makeT('DocumentSettings'); +const testId = makeTestId('test-settings-'); export class DocSettingsPage extends Disposable { private _docInfo = this._gristDoc.docInfo; @@ -53,6 +57,7 @@ export class DocSettingsPage extends Disposable { public buildDom() { const canChangeEngine = getSupportedEngineChoices().length > 0; const docPageModel = this._gristDoc.docPageModel; + const isTimingOn = this._gristDoc.isTimingOn; return cssContainer( dom.create(AdminSection, t('Document Settings'), [ @@ -80,26 +85,43 @@ export class DocSettingsPage extends Disposable { ]), dom.create(AdminSection, t('Data Engine'), [ - // dom.create(AdminSectionItem, { - // id: 'timings', - // name: t('Formula times'), - // description: t('Find slow formulas'), - // value: dom('div', t('Coming soon')), - // expandedContent: dom('div', t( - // 'Once you start timing, Grist will measure the time it takes to evaluate each formula. ' + - // 'This allows diagnosing which formulas are responsible for slow performance when a ' + - // 'document is first open, or when a document responds to changes.' - // )), - // }), + dom.create(AdminSectionItem, { + id: 'timings', + name: t('Formula timer'), + description: dom('div', + dom.maybe(isTimingOn, () => cssRedText(t('Timing is on') + '...')), + dom.maybe(not(isTimingOn), () => t('Find slow formulas')), + testId('timing-desc') + ), + value: dom.domComputed(isTimingOn, (timingOn) => { + if (timingOn) { + return dom('div', {style: 'display: flex; gap: 4px'}, + cssPrimarySmallLink( + t('Stop timing...'), + urlState().setHref({docPage: 'timing'}), + {target: '_blank'}, + testId('timing-stop') + ) + ); + } else { + return cssSmallButton(t('Start timing'), + dom.on('click', this._startTiming.bind(this)), + testId('timing-start') + ); + } + }), + expandedContent: dom('div', t( + 'Once you start timing, Grist will measure the time it takes to evaluate each formula. ' + + 'This allows diagnosing which formulas are responsible for slow performance when a ' + + 'document is first opened, or when a document responds to changes.' + )), + }), dom.create(AdminSectionItem, { id: 'reload', name: t('Reload'), description: t('Hard reset of data engine'), - value: cssSmallButton('Reload data engine', dom.on('click', async () => { - await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload(); - document.location.reload(); - })) + value: cssSmallButton(t('Reload data engine'), dom.on('click', this._reloadEngine.bind(this, true))), }), canChangeEngine ? dom.create(AdminSectionItem, { @@ -140,7 +162,7 @@ export class DocSettingsPage extends Disposable { cssWrap(t('Base doc URL: {{docApiUrl}}', { docApiUrl: cssCopyLink( {href: url}, - url, + dom('span', url), copyHandler(() => url, t("API URL copied to clipboard")), hoverTooltip(t('Copy to clipboard'), { key: TOOLTIP_KEY, @@ -170,10 +192,98 @@ export class DocSettingsPage extends Disposable { ); } + private async _reloadEngine(ask = true) { + const docPageModel = this._gristDoc.docPageModel; + const handler = async () => { + await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload(); + document.location.reload(); + }; + if (!ask) { + return handler(); + } + confirmModal(t('Reload data engine?'), t('Reload'), handler, { + explanation: t( + 'This will perform a hard reload of the data engine. This ' + + 'may help if the data engine is stuck in an infinite loop, is ' + + 'indefinitely processing the latest change, or has crashed. ' + + 'No data will be lost, except possibly currently pending actions.' + ) + }); + } + private async _setEngine(val: EngineCode|undefined) { confirmModal(t('Save and Reload'), t('Ok'), () => this._doSetEngine(val)); } + private async _startTiming() { + const docPageModel = this._gristDoc.docPageModel; + modal((ctl, owner) => { + this.onDispose(() => ctl.close()); + const selected = Observable.create