(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
This commit is contained in:
Jarosław Sadziński
2024-05-21 18:27:06 +02:00
parent 60423edc17
commit a6ffa6096a
29 changed files with 858 additions and 144 deletions

View File

@@ -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;

View File

@@ -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<Option>(owner, Option.Adhoc);
const page = Observable.create<TimingModalPage>(owner, TimingModalPage.Start);
const startTiming = async () => {
if (selected.get() === Option.Reload) {
page.set(TimingModalPage.Spinner);
await this._gristDoc.docApi.startTiming();
await docPageModel.appModel.api.getDocAPI(docPageModel.currentDocId.get()!).forceReload();
ctl.close();
urlState().pushUrl({docPage: 'timing'}).catch(reportError);
} else {
await this._gristDoc.docApi.startTiming();
ctl.close();
}
};
const startPage = () => [
cssRadioCheckboxOptions(
dom.style('max-width', '400px'),
radioCheckboxOption(selected, Option.Adhoc, dom('div',
dom('div',
dom('strong', t('Start timing')),
),
dom('div',
dom.style('margin-top', '8px'),
dom('span', t('You can make changes to the document, then stop timing to see the results.'))
),
testId('timing-modal-option-adhoc'),
)),
radioCheckboxOption(selected, Option.Reload, dom('div',
dom('div',
dom('strong', t('Time reload')),
),
dom('div',
dom.style('margin-top', '8px'),
dom('span', t('Force reload the document while timing formulas, and show the result.'))
),
testId('timing-modal-option-reload'),
))
),
cssModalButtons(
bigPrimaryButton(t(`Start timing`),
dom.on('click', startTiming),
testId('timing-modal-confirm'),
),
bigBasicButton(t('Cancel'), dom.on('click', () => ctl.close()), testId('timing-modal-cancel')),
)
];
const spinnerPage = () => [
cssSpinner(
loadingSpinner(),
testId('timing-modal-spinner'),
dom.style('width', 'fit-content')
),
];
return [
cssModalTitle(t(`Formula timer`)),
dom.domComputed(page, (p) => p === TimingModalPage.Start ? startPage() : spinnerPage()),
testId('timing-modal'),
];
});
}
private async _doSetEngine(val: EngineCode|undefined) {
const docPageModel = this._gristDoc.docPageModel;
if (this._engine.get() !== val) {
@@ -183,6 +293,8 @@ export class DocSettingsPage extends Disposable {
}
}
function getApiConsoleLink(docPageModel: DocPageModel) {
const url = new URL(location.href);
url.pathname = '/apiconsole';
@@ -231,6 +343,7 @@ const cssContainer = styled('div', `
position: relative;
height: 100%;
padding: 32px 64px 24px 64px;
color: ${theme.text};
@media ${mediaSmall} {
& {
padding: 32px 24px 24px 24px;
@@ -333,6 +446,29 @@ function clickToSelect() {
});
}
/**
* Enum for the different pages of the timing modal.
*/
enum TimingModalPage {
Start, // The initial page with options to start timing.
Spinner, // The page with a spinner while we are starting timing and reloading the document.
}
/**
* Enum for the different options in the timing modal.
*/
enum Option {
/**
* Start timing and immediately forces a reload of the document and waits for the
* document to be loaded, to show the results.
*/
Reload,
/**
* Just starts the timing, without reloading the document.
*/
Adhoc,
}
// A version that is not underlined, and on hover mouse pointer indicates that copy is available
const cssCopyLink = styled(cssLink, `
word-wrap: break-word;
@@ -364,3 +500,7 @@ const cssWrap = styled('p', `
word-break: break-all;
}
`);
const cssRedText = styled('span', `
color: ${theme.errorText};
`);

252
app/client/ui/TimingPage.ts Normal file
View File

@@ -0,0 +1,252 @@
import BaseView = require('app/client/components/BaseView');
import {GristDoc} from 'app/client/components/GristDoc';
import {ViewSectionHelper} from 'app/client/components/ViewLayout';
import {makeT} from 'app/client/lib/localization';
import {IEdit, IExternalTable, VirtualTable} from 'app/client/models/VirtualTable';
import {urlState} from 'app/client/models/gristUrlState';
import {docListHeader} from 'app/client/ui/DocMenuCss';
import {isNarrowScreenObs, mediaSmall} from 'app/client/ui2018/cssVars';
import {loadingSpinner} from 'app/client/ui2018/loaders';
import {FormulaTimingInfo} from 'app/common/ActiveDocAPI';
import {DisposableWithEvents} from 'app/common/DisposableWithEvents';
import {
DocAction,
getColValues,
TableDataAction} from 'app/common/DocActions';
import {VirtualId} from 'app/common/SortSpec';
import {not} from 'app/common/gutil';
import {Disposable, dom, makeTestId, Observable, styled} from 'grainjs';
import omit = require('lodash/omit');
import range = require('lodash/range');
const t = makeT('TimingPage');
const testId = makeTestId('test-timing-page-');
/**
* A list of columns for a virtual table about formula timings.
*/
const COLUMNS = [
{
id: VirtualId(),
colId: 'tableId',
type: 'Text',
label: t('Table ID'),
},
{
id: VirtualId(),
colId: 'colId',
type: 'Text',
label: t('Column ID'),
},
{
id: VirtualId(),
colId: 'sum',
type: 'Numeric',
label: t('Total Time (s)')
},
{
id: VirtualId(),
colId: 'calls',
type: 'Numeric',
label: t('Number of Calls')
},
{
id: VirtualId(),
colId: 'average',
type: 'Numeric',
label: t('Average Time (s)')
},
// max time
{
id: VirtualId(),
colId: 'max',
type: 'Numeric',
label: t('Max Time (s)')
},
] as const;
interface TimingRecord {
tableId: string;
colId: string;
sum: number;
calls: number;
average: number;
max: number;
}
const VIRTUAL_SECTION_ID = VirtualId();
const VIRTUAL_TABLE_ID = VirtualId();
/**
* Layout of fields in a view, with a specific ordering.
*/
const FIELDS: Array<(typeof COLUMNS)[number]['colId']> = [
'tableId', 'colId', 'sum', 'calls', 'average', 'max'
];
class TimingExternalTable extends Disposable implements IExternalTable {
public name = 'GristHidden_TimingTable';
public initialActions = _prepareInitialActions(this.name);
public saveableFields = [];
public constructor(private _initialData: FormulaTimingInfo[]) {
super();
}
public async fetchAll(): Promise<TableDataAction> {
const timingInfo = this._initialData;
console.debug('Timing info:', timingInfo);
const data = timingInfo || [];
const indicies = range(data.length).map(i => i + 1);
return ['TableData', this.name, indicies,
getColValues(indicies.map(rowId => _mapModelValues(rowId, data[rowId - 1])))];
}
// Not used.
public async beforeEdit(editor: IEdit) {}
public async afterEdit(editor: IEdit) {}
public async afterAnySchemaChange(editor: IEdit) {}
public async sync(editor: IEdit): Promise<void> {}
}
export class TimingPage extends DisposableWithEvents {
private _data: Observable<FormulaTimingInfo[]|null> = Observable.create(this, null);
constructor(private _gristDoc: GristDoc) {
super();
if (this._gristDoc.isTimingOn.get() === false) {
// Just redirect back to the settings page.
this._openSettings();
} else {
this._start().catch(ex => {
this._openSettings();
reportError(ex);
});
}
}
public buildDom() {
return cssContainer(
dom.maybe(this._data, () =>
dom('div', {style: 'display: flex; justify-content: space-between; align-items: baseline'},
cssHeader(t('Formula timer')),
)
),
dom.maybeOwned(this._data, (owner) => {
const viewSectionModel = this._gristDoc.docModel.viewSections.getRowModel(VIRTUAL_SECTION_ID as any);
ViewSectionHelper.create(owner, this._gristDoc, viewSectionModel);
return dom.maybe(use => use(viewSectionModel.viewInstance), (view: BaseView) =>
dom('div.active_section.view_data_pane_container.flexvbox', view.viewPane,
dom.maybe(use => !use(isNarrowScreenObs()), () => view.selectionSummary?.buildDom()),
)
);
}),
dom.maybe(not(this._data), () => cssLoaderScreen(
loadingSpinner(),
dom('div', t('Loading timing data. Don\'t close this tab.')),
testId('spinner'),
))
);
}
private _openSettings() {
urlState().pushUrl({docPage: 'settings'}).catch(reportError);
}
private async _start() {
const docApi = this._gristDoc.docPageModel.appModel.api.getDocAPI(this._gristDoc.docId());
// Get the data from the server (and wait for the engine to calculate everything if it hasn't already).
const data = await docApi.stopTiming();
if (this.isDisposed()) { return; }
// And wire up the UI.
const ext = this.autoDispose(new TimingExternalTable(data));
new VirtualTable(this, this._gristDoc, ext);
this._data.set(data);
}
}
// See the WebhookPage for more details on how this works.
function _prepareInitialActions(tableId: string): DocAction[] {
return [[
// Add the virtual table.
'AddTable', tableId,
COLUMNS.map(col => ({
isFormula: true,
type: 'Any',
formula: '',
id: col.colId
}))
], [
// Add an entry for the virtual table.
'AddRecord', '_grist_Tables', VIRTUAL_TABLE_ID as any, {tableId, primaryViewId: 0},
], [
// Add entries for the columns of the virtual table.
'BulkAddRecord', '_grist_Tables_column',
COLUMNS.map(col => col.id) as any, getColValues(COLUMNS.map(rec =>
Object.assign({
isFormula: false,
formula: '',
widgetOptions: '',
parentId: VIRTUAL_TABLE_ID as any,
}, omit(rec, ['id']) as any))),
], [
// Add a view section.
'AddRecord', '_grist_Views_section', VIRTUAL_SECTION_ID as any,
{
tableRef: VIRTUAL_TABLE_ID, parentKey: 'record',
title: 'Timing', layout: 'vertical', showHeader: true,
borderWidth: 1, defaultWidth: 100,
}
], [
// List the fields shown in the view section.
'BulkAddRecord', '_grist_Views_section_field', FIELDS.map(VirtualId.bind(null, undefined)) as any, {
colRef: FIELDS.map(colId => COLUMNS.find(r => r.colId === colId)!.id),
parentId: FIELDS.map(() => VIRTUAL_SECTION_ID),
parentPos: FIELDS.map((_, i) => i),
}
]];
}
// See the WebhookPage for more details on how this works.
function _mapModelValues(rowId: number, model: FormulaTimingInfo): Partial<TimingRecord & {id: number}> {
return {
id: rowId,
tableId: model.tableId,
colId: model.colId,
sum: model.sum,
calls: model.count,
average: model.average,
max: model.max,
};
}
const cssHeader = styled(docListHeader, `
margin-bottom: 18px;
`);
const cssContainer = styled('div', `
overflow-y: auto;
position: relative;
height: 100%;
padding: 32px 64px 24px 64px;
display: flex;
flex-direction: column;
@media ${mediaSmall} {
& {
padding: 32px 24px 24px 24px;
}
}
`);
const cssLoaderScreen = styled('div', `
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
flex: 1;
`);

View File

@@ -16,6 +16,7 @@ import {
TableDataAction,
UserAction
} from 'app/common/DocActions';
import {VirtualId} from 'app/common/SortSpec';
import {WebhookSummary} from 'app/common/Triggers';
import {DocAPI} from 'app/common/UserAPI';
import {GristObjCode, RowRecord} from 'app/plugin/GristData';
@@ -28,13 +29,15 @@ import without = require('lodash/without');
const t = makeT('WebhookPage');
const TABLE_COLUMN_ROW_ID = VirtualId();
/**
* A list of columns for a virtual table about webhooks.
* The ids need to be strings.
*/
const WEBHOOK_COLUMNS = [
{
id: 'vt_webhook_fc1',
id: TABLE_COLUMN_ROW_ID,
colId: 'tableId',
type: 'Choice',
label: t('Table'),
@@ -42,13 +45,13 @@ const WEBHOOK_COLUMNS = [
// on the user tables in the document.
},
{
id: 'vt_webhook_fc2',
id: VirtualId(),
colId: 'url',
type: 'Text',
label: t('URL'),
},
{
id: 'vt_webhook_fc3',
id: VirtualId(),
colId: 'eventTypes',
type: 'ChoiceList',
label: t('Event Types'),
@@ -60,13 +63,13 @@ const WEBHOOK_COLUMNS = [
}),
},
{
id: 'vt_webhook_fc10',
id: VirtualId(),
colId: 'watchedColIdsText',
type: 'Text',
label: t('Filter for changes in these columns (semicolon-separated ids)'),
},
{
id: 'vt_webhook_fc4',
id: VirtualId(),
colId: 'enabled',
type: 'Bool',
label: t('Enabled'),
@@ -75,31 +78,31 @@ const WEBHOOK_COLUMNS = [
}),
},
{
id: 'vt_webhook_fc5',
id: VirtualId(),
colId: 'isReadyColumn',
type: 'Text',
label: t('Ready Column'),
},
{
id: 'vt_webhook_fc6',
id: VirtualId(),
colId: 'webhookId',
type: 'Text',
label: t('Webhook Id'),
},
{
id: 'vt_webhook_fc7',
id: VirtualId(),
colId: 'name',
type: 'Text',
label: t('Name'),
},
{
id: 'vt_webhook_fc8',
id: VirtualId(),
colId: 'memo',
type: 'Text',
label: t('Memo'),
},
{
id: 'vt_webhook_fc9',
id: VirtualId(),
colId: 'status',
type: 'Text',
label: t('Status'),
@@ -264,7 +267,7 @@ class WebhookExternalTable implements IExternalTable {
// Grist doesn't have a good way to handle contingent choices.
const choices = editor.gristDoc.docModel.visibleTables.all().map(tableRec => tableRec.tableId());
editor.gristDoc.docData.receiveAction([
'UpdateRecord', '_grist_Tables_column', 'vt_webhook_fc1' as any, {
'UpdateRecord', '_grist_Tables_column', TABLE_COLUMN_ROW_ID as any, {
widgetOptions: JSON.stringify({
widget: 'TextBox',
alignment: 'left',