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 { 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 {} } export class TimingPage extends DisposableWithEvents { private _data: Observable = 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 { 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; `);